Skip to main content

tldr_cli/commands/bugbot/
runner.rs

1//! Tool execution engine for L1 commodity diagnostics
2//!
3//! Spawns diagnostic tools as subprocesses, captures their output, parses it
4//! through the appropriate parser, and handles timeouts and failures.
5//!
6//! Key behaviors:
7//! - Binary not found: returns `ToolResult { success: false }` with spawn error
8//! - Tool timeout: kills child process, returns timeout error
9//! - Non-zero exit with parseable output: `success: true` (linters exit non-zero on findings)
10//! - Parse error: `success: false` with parse error detail
11//! - After parsing: injects `tool.name` into each `L1Finding.tool` [PM-6]
12//!
13//! Parallel execution uses `std::thread::scope` (Rust 1.63+) for safe scoped threads.
14
15use 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
24/// Kill a process by its OS-level PID. Cross-platform (F3).
25///
26/// On Unix, sends SIGKILL via libc. On Windows, uses `TerminateProcess`
27/// via the `windows-sys` crate (or raw WinAPI). This is needed because the
28/// watchdog thread only has the PID, not the `Child` handle (which is
29/// consumed by `wait_with_output`).
30fn kill_process_by_id(pid: u32) {
31    #[cfg(unix)]
32    {
33        // SAFETY: We are sending SIGKILL to a process we spawned.
34        // The PID is valid because we obtained it from child.id() before
35        // the watchdog thread was spawned.
36        unsafe {
37            libc::kill(pid as libc::pid_t, libc::SIGKILL);
38        }
39    }
40    #[cfg(windows)]
41    {
42        // On Windows, open the process handle and terminate it.
43        // SAFETY: We spawned this process and hold a valid PID.
44        unsafe {
45            let handle = windows_sys::Win32::System::Threading::OpenProcess(
46                windows_sys::Win32::System::Threading::PROCESS_TERMINATE,
47                0, // bInheritHandle = FALSE
48                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        // Unsupported platform: log a warning. The timeout flag is still
59        // set, so the result will report a timeout even if the process
60        // continues running.
61        eprintln!("bugbot: cannot kill process {} on this platform", pid);
62    }
63}
64
65/// Maximum bytes of stdout/stderr to retain from a tool subprocess.
66///
67/// This is a safety valve: clippy on a large project can produce megabytes
68/// of JSON output. Beyond this limit, output is truncated to prevent
69/// unbounded memory growth. 10 MB is generous for any reasonable project.
70pub const MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024; // 10 MB
71
72/// Executes diagnostic tools and captures their output.
73///
74/// Each tool is run as a subprocess with configurable timeout. Output is
75/// captured and fed through the parser identified by `ToolConfig::parser`.
76pub struct ToolRunner {
77    /// Timeout per tool in seconds
78    timeout_secs: u64,
79}
80
81impl ToolRunner {
82    /// Create a new `ToolRunner` with the given per-tool timeout in seconds.
83    pub fn new(timeout_secs: u64) -> Self {
84        Self { timeout_secs }
85    }
86
87    /// Run a single tool and parse its output.
88    ///
89    /// # Contract
90    /// - Binary not found: `ToolResult { success: false, error: "spawn" message }`
91    /// - Tool timeout: kill child, `ToolResult { success: false, error: "Timeout" }`
92    /// - Tool crashes (non-zero exit) with parseable output: `success: true`
93    ///   (linters exit non-zero when findings exist)
94    /// - Tool crashes with unparseable output: `success: false`
95    /// - Parse error: `ToolResult { success: false, error: "Parse error: ..." }`
96    /// - After parsing, injects `tool.name` into each `L1Finding.tool` [PM-6]
97    pub fn run_tool(&self, tool: &ToolConfig, project_path: &Path) -> (ToolResult, Vec<L1Finding>) {
98        let start = Instant::now();
99
100        // Spawn the subprocess
101        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!("Failed to spawn '{}': {}", tool.binary, e)),
119                        exit_code: None,
120                    },
121                    vec![],
122                );
123            }
124        };
125
126        // Set up timeout watchdog thread.
127        // The watchdog sleeps for timeout_secs, then kills the process via SIGKILL.
128        // Meanwhile the main thread calls wait_with_output() which blocks until
129        // the child exits (either naturally or via the kill signal).
130        let timeout = Duration::from_secs(self.timeout_secs);
131        let child_id = child.id();
132        let timed_out = Arc::new(AtomicBool::new(false));
133        let timed_out_clone = timed_out.clone();
134
135        let _watchdog = std::thread::spawn(move || {
136            std::thread::sleep(timeout);
137            timed_out_clone.store(true, Ordering::SeqCst);
138            // Kill the child process. Platform-specific because we only have
139            // the PID (the Child handle is consumed by wait_with_output).
140            kill_process_by_id(child_id);
141        });
142
143        // Block until child exits (naturally or killed by watchdog)
144        let output = child.wait_with_output();
145        let duration_ms = start.elapsed().as_millis() as u64;
146
147        // Check if watchdog triggered
148        if timed_out.load(Ordering::SeqCst) {
149            return (
150                ToolResult {
151                    name: tool.name.to_string(),
152                    category: tool.category,
153                    success: false,
154                    duration_ms,
155                    finding_count: 0,
156                    error: Some(format!("Timeout after {}s", self.timeout_secs)),
157                    exit_code: None,
158                },
159                vec![],
160            );
161        }
162
163        // Read output and truncate to MAX_OUTPUT_BYTES to prevent unbounded
164        // memory growth (F1 safety valve).
165        let (stdout, stderr, exit_code) = match output {
166            Ok(o) => {
167                let raw_stdout = String::from_utf8_lossy(&o.stdout).to_string();
168                let raw_stderr = String::from_utf8_lossy(&o.stderr).to_string();
169                let stdout = if raw_stdout.len() > MAX_OUTPUT_BYTES {
170                    let mut truncated = raw_stdout;
171                    truncated.truncate(MAX_OUTPUT_BYTES);
172                    // Trim to last complete line to avoid breaking JSON parsing
173                    if let Some(last_newline) = truncated.rfind('\n') {
174                        truncated.truncate(last_newline + 1);
175                    }
176                    truncated
177                } else {
178                    raw_stdout
179                };
180                let stderr = if raw_stderr.len() > MAX_OUTPUT_BYTES {
181                    let mut truncated = raw_stderr;
182                    truncated.truncate(MAX_OUTPUT_BYTES);
183                    truncated
184                } else {
185                    raw_stderr
186                };
187                (stdout, stderr, o.status.code())
188            }
189            Err(e) => {
190                return (
191                    ToolResult {
192                        name: tool.name.to_string(),
193                        category: tool.category,
194                        success: false,
195                        duration_ms,
196                        finding_count: 0,
197                        error: Some(format!("Failed to read output: {}", e)),
198                        exit_code: None,
199                    },
200                    vec![],
201                );
202            }
203        };
204
205        // Parse output through the tool's parser
206        match parsers::parse_tool_output(tool.parser, &stdout) {
207            Ok(mut findings) => {
208                // PM-6: Inject tool name into each finding
209                for f in &mut findings {
210                    f.tool = tool.name.to_string();
211                }
212                let count = findings.len();
213                (
214                    ToolResult {
215                        name: tool.name.to_string(),
216                        category: tool.category,
217                        success: true,
218                        duration_ms,
219                        finding_count: count,
220                        error: None,
221                        exit_code,
222                    },
223                    findings,
224                )
225            }
226            Err(e) => {
227                // If parse failed, include truncated stderr for diagnostics
228                let error_msg = if stderr.is_empty() {
229                    format!("Parse error: {}", e)
230                } else {
231                    let truncated = if stderr.len() > 200 {
232                        &stderr[..200]
233                    } else {
234                        &stderr
235                    };
236                    format!("Parse error: {}. stderr: {}", e, truncated.trim())
237                };
238                (
239                    ToolResult {
240                        name: tool.name.to_string(),
241                        category: tool.category,
242                        success: false,
243                        duration_ms,
244                        finding_count: 0,
245                        error: Some(error_msg),
246                        exit_code,
247                    },
248                    vec![],
249                )
250            }
251        }
252    }
253
254    /// Run multiple tools in parallel, collecting results.
255    ///
256    /// # Contract
257    /// - One tool failure does not block others
258    /// - Results are in deterministic order (same as input `tools` order)
259    /// - All findings have tool name injected [PM-6]
260    /// - Single tool or empty list: runs sequentially (no thread overhead)
261    pub fn run_tools_parallel(
262        &self,
263        tools: &[&ToolConfig],
264        project_path: &Path,
265    ) -> (Vec<ToolResult>, Vec<L1Finding>) {
266        if tools.len() <= 1 {
267            return self.run_tools_sequential(tools, project_path);
268        }
269
270        // Parallel execution using scoped threads (Rust 1.63+)
271        // Scoped threads allow borrowing from the enclosing scope safely.
272        let results: Vec<(usize, ToolResult, Vec<L1Finding>)> = std::thread::scope(|s| {
273            let handles: Vec<_> = tools
274                .iter()
275                .enumerate()
276                .map(|(i, tool)| {
277                    let tool_name = tool.name;
278                    let tool_category = tool.category;
279                    let path = project_path;
280                    let handle = s.spawn(move || {
281                        let (result, findings) = self.run_tool(tool, path);
282                        (i, result, findings)
283                    });
284                    (handle, i, tool_name, tool_category)
285                })
286                .collect();
287
288            // F4: Convert thread panics into ToolResult with success=false
289            // instead of propagating the panic to the parent thread.
290            handles
291                .into_iter()
292                .map(|(h, idx, name, category)| match h.join() {
293                    Ok(result) => result,
294                    Err(_) => {
295                        eprintln!("bugbot: tool thread for '{}' panicked", name);
296                        (
297                            idx,
298                            ToolResult {
299                                name: name.to_string(),
300                                category,
301                                success: false,
302                                duration_ms: 0,
303                                finding_count: 0,
304                                error: Some("Tool thread panicked".to_string()),
305                                exit_code: None,
306                            },
307                            vec![],
308                        )
309                    }
310                })
311                .collect()
312        });
313
314        // Sort by original index to maintain deterministic order
315        let mut sorted = results;
316        sorted.sort_by_key(|(i, _, _)| *i);
317
318        let mut all_results = Vec::new();
319        let mut all_findings = Vec::new();
320        for (_idx, result, findings) in sorted {
321            all_results.push(result);
322            all_findings.extend(findings);
323        }
324
325        (all_results, all_findings)
326    }
327
328    /// Run tools sequentially. Used when there is 0 or 1 tool.
329    fn run_tools_sequential(
330        &self,
331        tools: &[&ToolConfig],
332        project_path: &Path,
333    ) -> (Vec<ToolResult>, Vec<L1Finding>) {
334        let mut all_results = Vec::new();
335        let mut all_findings = Vec::new();
336        for tool in tools {
337            let (result, findings) = self.run_tool(tool, project_path);
338            all_results.push(result);
339            all_findings.extend(findings);
340        }
341        (all_results, all_findings)
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::commands::bugbot::tools::ToolCategory;
349
350    /// Helper: create a ToolConfig with the given parameters.
351    /// Uses Box::leak to create &'static str / &'static [&'static str] from owned data.
352    fn make_tool(
353        name: &'static str,
354        binary: &'static str,
355        args: &'static [&'static str],
356        parser: &'static str,
357        category: ToolCategory,
358    ) -> ToolConfig {
359        ToolConfig {
360            name,
361            binary,
362            detection_binary: binary,
363            args,
364            category,
365            parser,
366        }
367    }
368
369    // =========================================================================
370    // Test 1: Binary not found
371    // =========================================================================
372
373    #[test]
374    fn test_run_tool_binary_not_found() {
375        let runner = ToolRunner::new(10);
376        let tool = make_tool(
377            "missing-tool",
378            "nonexistent-binary-xyz-12345",
379            &[],
380            "cargo",
381            ToolCategory::Linter,
382        );
383
384        let (result, findings) = runner.run_tool(&tool, Path::new("."));
385
386        assert!(!result.success, "should fail for missing binary");
387        assert!(result.error.is_some(), "should have error message");
388        let err = result.error.as_ref().unwrap();
389        assert!(
390            err.contains("spawn") || err.contains("not found") || err.contains("No such file"),
391            "error should mention spawn failure, got: {}",
392            err
393        );
394        assert!(findings.is_empty(), "no findings for missing binary");
395        assert_eq!(result.name, "missing-tool");
396        assert_eq!(result.finding_count, 0);
397        assert!(result.exit_code.is_none());
398    }
399
400    // =========================================================================
401    // Test 2: Timeout handling
402    // =========================================================================
403
404    #[test]
405    fn test_run_tool_timeout() {
406        let runner = ToolRunner::new(1); // 1 second timeout
407        let tool = make_tool(
408            "sleeper",
409            "sleep",
410            &["10"], // sleep for 10 seconds, will be killed after 1
411            "cargo",
412            ToolCategory::Linter,
413        );
414
415        let start = Instant::now();
416        let (result, findings) = runner.run_tool(&tool, Path::new("."));
417        let elapsed = start.elapsed();
418
419        assert!(!result.success, "should fail on timeout");
420        assert!(
421            result.error.as_ref().unwrap().contains("imeout"),
422            "error should mention timeout, got: {:?}",
423            result.error
424        );
425        assert!(findings.is_empty(), "no findings on timeout");
426        // Should have taken roughly 1 second, not 10
427        assert!(
428            elapsed.as_secs() < 5,
429            "should have been killed within ~1s, took {:?}",
430            elapsed
431        );
432        assert!(
433            result.duration_ms >= 900,
434            "should have waited at least ~1s, got {}ms",
435            result.duration_ms
436        );
437    }
438
439    // =========================================================================
440    // Test 3: Tool name injection [PM-6]
441    // =========================================================================
442
443    // sh -c command that outputs a valid cargo NDJSON compiler-message line.
444    // Uses printf '%s\n' to avoid the shell interpreting escape sequences
445    // (echo interprets \n in the rendered field as a literal newline, splitting
446    // the JSON across lines). The rendered field omits \n since the parser
447    // does not use it.
448    const SH_CMD_ONE_WARNING: &str = concat!(
449        "printf '%s\\n' '",
450        r#"{"reason":"compiler-message","package_id":"test 0.1.0","manifest_path":"/test/Cargo.toml","#,
451        r#""target":{"kind":["lib"],"crate_types":["lib"],"name":"test","src_path":"/test/src/lib.rs","edition":"2021","doc":false,"doctest":false,"test":false},"#,
452        r#""message":{"rendered":"warning: unused","children":[],"code":{"code":"unused_variables","explanation":null},"level":"warning","message":"unused variable","#,
453        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":[]}]}}"#,
454        "'",
455    );
456
457    #[test]
458    fn test_run_tool_injects_tool_name() {
459        let runner = ToolRunner::new(10);
460        let tool = make_tool(
461            "test-clippy",
462            "sh",
463            &["-c", SH_CMD_ONE_WARNING],
464            "cargo",
465            ToolCategory::Linter,
466        );
467
468        let (result, findings) = runner.run_tool(&tool, Path::new("."));
469
470        assert!(
471            result.success,
472            "tool should succeed, error: {:?}",
473            result.error
474        );
475        assert_eq!(findings.len(), 1, "should have 1 finding");
476        assert_eq!(
477            findings[0].tool, "test-clippy",
478            "PM-6: tool name should be injected by runner, got: '{}'",
479            findings[0].tool
480        );
481    }
482
483    // =========================================================================
484    // Test 4: Parse error captured
485    // =========================================================================
486
487    #[test]
488    fn test_run_tool_parse_error_captured() {
489        // echo outputs garbage that the cargo-audit parser can't parse
490        // (cargo-audit expects valid JSON, not NDJSON)
491        let runner = ToolRunner::new(10);
492        let tool = make_tool(
493            "bad-output",
494            "echo",
495            &["this is not valid json at all"],
496            "cargo-audit", // expects a single JSON object
497            ToolCategory::SecurityScanner,
498        );
499
500        let (result, findings) = runner.run_tool(&tool, Path::new("."));
501
502        assert!(!result.success, "should fail on parse error");
503        assert!(
504            result.error.as_ref().unwrap().contains("Parse error"),
505            "error should mention parse error, got: {:?}",
506            result.error
507        );
508        assert!(findings.is_empty(), "no findings on parse error");
509    }
510
511    // =========================================================================
512    // Test 5: Parallel failure isolation
513    // =========================================================================
514
515    #[test]
516    fn test_run_tools_parallel_failure_isolation() {
517        let runner = ToolRunner::new(10);
518
519        // Tool A succeeds: echo empty string -> cargo parser returns Ok(vec![])
520        let tool_a = make_tool("tool-a", "echo", &[""], "cargo", ToolCategory::Linter);
521
522        // Tool B fails: nonexistent binary
523        let tool_b = make_tool(
524            "tool-b",
525            "nonexistent-binary-xyz-12345",
526            &[],
527            "cargo",
528            ToolCategory::Linter,
529        );
530
531        let tools: Vec<&ToolConfig> = vec![&tool_a, &tool_b];
532        let (results, _findings) = runner.run_tools_parallel(&tools, Path::new("."));
533
534        assert_eq!(results.len(), 2, "should have 2 results");
535        assert!(
536            results[0].success,
537            "tool-a should succeed, error: {:?}",
538            results[0].error
539        );
540        assert!(!results[1].success, "tool-b should fail");
541        assert_eq!(results[0].name, "tool-a");
542        assert_eq!(results[1].name, "tool-b");
543    }
544
545    // =========================================================================
546    // Test 6: Parallel deterministic order
547    // =========================================================================
548
549    #[test]
550    fn test_run_tools_parallel_deterministic_order() {
551        let runner = ToolRunner::new(10);
552
553        let tool_alpha = make_tool("alpha", "echo", &[""], "cargo", ToolCategory::Linter);
554        let tool_beta = make_tool("beta", "echo", &[""], "cargo", ToolCategory::Linter);
555        let tool_gamma = make_tool("gamma", "echo", &[""], "cargo", ToolCategory::Linter);
556
557        let tools: Vec<&ToolConfig> = vec![&tool_alpha, &tool_beta, &tool_gamma];
558        let (results, _findings) = runner.run_tools_parallel(&tools, Path::new("."));
559
560        assert_eq!(results.len(), 3);
561        assert_eq!(
562            results[0].name, "alpha",
563            "first result should be alpha, got {}",
564            results[0].name
565        );
566        assert_eq!(
567            results[1].name, "beta",
568            "second result should be beta, got {}",
569            results[1].name
570        );
571        assert_eq!(
572            results[2].name, "gamma",
573            "third result should be gamma, got {}",
574            results[2].name
575        );
576    }
577
578    // =========================================================================
579    // Test 7: Sequential for single tool
580    // =========================================================================
581
582    #[test]
583    fn test_run_tools_sequential_for_single_tool() {
584        let runner = ToolRunner::new(10);
585        let tool = make_tool("solo", "echo", &[""], "cargo", ToolCategory::Linter);
586
587        let tools: Vec<&ToolConfig> = vec![&tool];
588        let (results, findings) = runner.run_tools_parallel(&tools, Path::new("."));
589
590        assert_eq!(results.len(), 1);
591        assert!(results[0].success);
592        assert_eq!(results[0].name, "solo");
593        assert!(findings.is_empty(), "empty echo -> no cargo findings");
594    }
595
596    // =========================================================================
597    // Test 8: Non-zero exit with parseable output = success
598    // =========================================================================
599
600    // sh -c command that outputs a valid NDJSON line and exits with code 1
601    const SH_CMD_WARNING_EXIT1: &str = concat!(
602        "printf '%s\\n' '",
603        r#"{"reason":"compiler-message","package_id":"test 0.1.0","manifest_path":"/test/Cargo.toml","#,
604        r#""target":{"kind":["lib"],"crate_types":["lib"],"name":"test","src_path":"/test/src/lib.rs","edition":"2021","doc":false,"doctest":false,"test":false},"#,
605        r#""message":{"rendered":"warning: unused","children":[],"code":{"code":"unused_variables","explanation":null},"level":"warning","message":"unused variable","#,
606        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":[]}]}}"#,
607        "'; exit 1",
608    );
609
610    #[test]
611    fn test_run_tool_nonzero_exit_with_parseable_output() {
612        // Linters exit non-zero when they find issues. That's not a failure.
613        let runner = ToolRunner::new(10);
614        let tool = make_tool(
615            "linter-with-findings",
616            "sh",
617            &["-c", SH_CMD_WARNING_EXIT1],
618            "cargo",
619            ToolCategory::Linter,
620        );
621
622        let (result, findings) = runner.run_tool(&tool, Path::new("."));
623
624        assert!(
625            result.success,
626            "non-zero exit with parseable output should be success, error: {:?}",
627            result.error
628        );
629        assert_eq!(
630            result.exit_code,
631            Some(1),
632            "exit code should be captured as 1"
633        );
634        assert_eq!(findings.len(), 1, "should have parsed 1 finding");
635        assert_eq!(
636            findings[0].tool, "linter-with-findings",
637            "tool name should be injected"
638        );
639    }
640
641    // =========================================================================
642    // Test 9: Empty tool list
643    // =========================================================================
644
645    #[test]
646    fn test_run_tools_parallel_empty_list() {
647        let runner = ToolRunner::new(10);
648        let tools: Vec<&ToolConfig> = vec![];
649        let (results, findings) = runner.run_tools_parallel(&tools, Path::new("."));
650
651        assert!(results.is_empty(), "no tools = no results");
652        assert!(findings.is_empty(), "no tools = no findings");
653    }
654
655    // =========================================================================
656    // Test 10: Success with echo (no findings)
657    // =========================================================================
658
659    #[test]
660    fn test_run_tool_success_echo_empty_output() {
661        // echo "" produces effectively empty output, cargo parser returns empty vec
662        let runner = ToolRunner::new(10);
663        let tool = make_tool("echo-tool", "echo", &[""], "cargo", ToolCategory::Linter);
664
665        let (result, findings) = runner.run_tool(&tool, Path::new("."));
666
667        assert!(
668            result.success,
669            "echo should succeed, error: {:?}",
670            result.error
671        );
672        assert_eq!(result.finding_count, 0);
673        assert!(findings.is_empty());
674        assert!(result.error.is_none());
675        assert_eq!(result.name, "echo-tool");
676    }
677
678    // =========================================================================
679    // Test 11: Duration is tracked
680    // =========================================================================
681
682    #[test]
683    fn test_run_tool_tracks_duration() {
684        let runner = ToolRunner::new(10);
685        let tool = make_tool(
686            "timer-test",
687            "echo",
688            &["hello"],
689            "cargo",
690            ToolCategory::Linter,
691        );
692
693        let (result, _findings) = runner.run_tool(&tool, Path::new("."));
694
695        // Duration should be positive (process did run)
696        // We can't assert exact timing but it shouldn't be huge
697        assert!(
698            result.duration_ms < 5000,
699            "echo should complete in well under 5s, got {}ms",
700            result.duration_ms
701        );
702    }
703
704    // =========================================================================
705    // Test 12: Category preserved in result
706    // =========================================================================
707
708    #[test]
709    fn test_run_tool_preserves_category() {
710        let runner = ToolRunner::new(10);
711        let tool = make_tool(
712            "security-tool",
713            "echo",
714            &[""],
715            "cargo",
716            ToolCategory::SecurityScanner,
717        );
718
719        let (result, _findings) = runner.run_tool(&tool, Path::new("."));
720
721        assert_eq!(
722            result.category,
723            ToolCategory::SecurityScanner,
724            "category should be preserved from ToolConfig"
725        );
726    }
727
728    // =========================================================================
729    // Test 13: Build-finished only output (valid JSON, no findings)
730    // =========================================================================
731
732    #[test]
733    fn test_run_tool_build_finished_only() {
734        // cargo outputs build-finished at the end; that's not a compiler-message
735        let runner = ToolRunner::new(10);
736        let tool = make_tool(
737            "cargo-noop",
738            "echo",
739            &[r#"{"reason":"build-finished","success":true}"#],
740            "cargo",
741            ToolCategory::Linter,
742        );
743
744        let (result, findings) = runner.run_tool(&tool, Path::new("."));
745
746        assert!(result.success, "valid output should succeed");
747        assert_eq!(result.finding_count, 0, "build-finished is not a finding");
748        assert!(findings.is_empty());
749    }
750
751    // =========================================================================
752    // Test 14: Unknown parser name
753    // =========================================================================
754
755    #[test]
756    fn test_run_tool_unknown_parser() {
757        let runner = ToolRunner::new(10);
758        let tool = make_tool(
759            "bad-parser",
760            "echo",
761            &["some output"],
762            "nonexistent-parser",
763            ToolCategory::Linter,
764        );
765
766        let (result, findings) = runner.run_tool(&tool, Path::new("."));
767
768        assert!(!result.success, "unknown parser should fail");
769        assert!(
770            result.error.as_ref().unwrap().contains("Parse error"),
771            "should mention parse error, got: {:?}",
772            result.error
773        );
774        assert!(findings.is_empty());
775    }
776
777    // =========================================================================
778    // Test 15: Multiple findings have tool name injected
779    // =========================================================================
780
781    // sh -c command that outputs two NDJSON lines + build-finished
782    const SH_CMD_TWO_WARNINGS: &str = concat!(
783        "printf '%s\\n' '",
784        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":[]}]}}"#,
785        "'; printf '%s\\n' '",
786        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":[]}]}}"#,
787        "'; printf '%s\\n' '",
788        r#"{"reason":"build-finished","success":true}"#,
789        "'",
790    );
791
792    #[test]
793    fn test_run_tool_multiple_findings_all_have_tool_name() {
794        let runner = ToolRunner::new(10);
795        let tool = make_tool(
796            "multi-finder",
797            "sh",
798            &["-c", SH_CMD_TWO_WARNINGS],
799            "cargo",
800            ToolCategory::Linter,
801        );
802
803        let (result, findings) = runner.run_tool(&tool, Path::new("."));
804
805        assert!(result.success, "should succeed, error: {:?}", result.error);
806        assert_eq!(findings.len(), 2, "should have 2 findings");
807        for (i, f) in findings.iter().enumerate() {
808            assert_eq!(
809                f.tool, "multi-finder",
810                "PM-6: finding[{}].tool should be 'multi-finder', got '{}'",
811                i, f.tool
812            );
813        }
814    }
815
816    // =========================================================================
817    // Test: F1 - Stdout/stderr truncation constant exists
818    // =========================================================================
819
820    #[test]
821    fn test_max_output_bytes_constant_exists() {
822        // F1: There should be a safety limit on captured stdout/stderr
823        let max_output_bytes = std::hint::black_box(super::MAX_OUTPUT_BYTES);
824        assert!(
825            max_output_bytes > 0,
826            "MAX_OUTPUT_BYTES should be a positive constant"
827        );
828        assert!(
829            max_output_bytes >= 1_000_000,
830            "MAX_OUTPUT_BYTES should be at least 1MB, got {}",
831            max_output_bytes
832        );
833    }
834
835    #[test]
836    fn test_large_stdout_is_truncated() {
837        // F1: When a tool produces output larger than MAX_OUTPUT_BYTES,
838        // the output should be truncated and parsing should still work.
839        let runner = ToolRunner::new(10);
840
841        // Generate output larger than MAX_OUTPUT_BYTES by repeating a valid
842        // NDJSON line many times. Each line is ~500 bytes, so we need
843        // MAX_OUTPUT_BYTES / 500 + 1 lines to exceed the limit.
844        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":[]}]}}"#;
845
846        let line_count = (super::MAX_OUTPUT_BYTES / single_line.len()) + 100;
847
848        // Build a shell command that prints the line N times
849        let sh_cmd = format!(
850            "for i in $(seq 1 {}); do printf '%s\\n' '{}'; done",
851            line_count, single_line
852        );
853
854        let tool = make_tool(
855            "large-output",
856            "sh",
857            // We need to leak the strings for 'static lifetime
858            Box::leak(Box::new(["-c", Box::leak(sh_cmd.into_boxed_str()) as &str])) as &[&str],
859            "cargo",
860            ToolCategory::Linter,
861        );
862
863        let (result, _findings) = runner.run_tool(&tool, Path::new("."));
864
865        // The tool should still succeed (output is truncated but parseable)
866        assert!(
867            result.success,
868            "should succeed even with truncated output, error: {:?}",
869            result.error
870        );
871    }
872
873    // =========================================================================
874    // Test: F4 - Thread panic converts to error result
875    // =========================================================================
876
877    #[test]
878    fn test_thread_panic_does_not_propagate() {
879        // F4: If a tool thread panics, the parent should NOT panic.
880        // Instead, it should produce a ToolResult with success=false.
881        // We can't easily trigger a panic inside run_tool, but we can
882        // verify the structural expectation: run_tools_parallel should
883        // return results for all tools even if one has issues.
884        let runner = ToolRunner::new(10);
885
886        let tool_a = make_tool("good-tool", "echo", &[""], "cargo", ToolCategory::Linter);
887
888        // Tool with a binary that will fail to spawn
889        let tool_b = make_tool(
890            "bad-tool",
891            "nonexistent-binary-xyz-12345",
892            &[],
893            "cargo",
894            ToolCategory::Linter,
895        );
896
897        let tools: Vec<&ToolConfig> = vec![&tool_a, &tool_b];
898        let (results, _findings) = runner.run_tools_parallel(&tools, Path::new("."));
899
900        // Both tools should produce results (no panic propagation)
901        assert_eq!(results.len(), 2, "should have results for both tools");
902    }
903}