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!(
119                            "Failed to spawn '{}': {}",
120                            tool.binary, e
121                        )),
122                        exit_code: None,
123                    },
124                    vec![],
125                );
126            }
127        };
128
129        // Set up timeout watchdog thread.
130        // The watchdog sleeps for timeout_secs, then kills the process via SIGKILL.
131        // Meanwhile the main thread calls wait_with_output() which blocks until
132        // the child exits (either naturally or via the kill signal).
133        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 the child process. Platform-specific because we only have
142            // the PID (the Child handle is consumed by wait_with_output).
143            kill_process_by_id(child_id);
144        });
145
146        // Block until child exits (naturally or killed by watchdog)
147        let output = child.wait_with_output();
148        let duration_ms = start.elapsed().as_millis() as u64;
149
150        // Check if watchdog triggered
151        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        // Read output and truncate to MAX_OUTPUT_BYTES to prevent unbounded
167        // memory growth (F1 safety valve).
168        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                    // Trim to last complete line to avoid breaking JSON parsing
176                    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        // Parse output through the tool's parser
209        match parsers::parse_tool_output(tool.parser, &stdout) {
210            Ok(mut findings) => {
211                // PM-6: Inject tool name into each finding
212                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                // If parse failed, include truncated stderr for diagnostics
231                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    /// Run multiple tools in parallel, collecting results.
258    ///
259    /// # Contract
260    /// - One tool failure does not block others
261    /// - Results are in deterministic order (same as input `tools` order)
262    /// - All findings have tool name injected [PM-6]
263    /// - Single tool or empty list: runs sequentially (no thread overhead)
264    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        // Parallel execution using scoped threads (Rust 1.63+)
274        // Scoped threads allow borrowing from the enclosing scope safely.
275        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            // F4: Convert thread panics into ToolResult with success=false
292            // instead of propagating the panic to the parent thread.
293            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        // Sort by original index to maintain deterministic order
320        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    /// Run tools sequentially. Used when there is 0 or 1 tool.
334    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    /// Helper: create a ToolConfig with the given parameters.
356    /// Uses Box::leak to create &'static str / &'static [&'static str] from owned data.
357    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    // =========================================================================
375    // Test 1: Binary not found
376    // =========================================================================
377
378    #[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    // =========================================================================
409    // Test 2: Timeout handling
410    // =========================================================================
411
412    #[test]
413    fn test_run_tool_timeout() {
414        let runner = ToolRunner::new(1); // 1 second timeout
415        let tool = make_tool(
416            "sleeper",
417            "sleep",
418            &["10"], // sleep for 10 seconds, will be killed after 1
419            "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        // Should have taken roughly 1 second, not 10
435        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    // =========================================================================
448    // Test 3: Tool name injection [PM-6]
449    // =========================================================================
450
451    // sh -c command that outputs a valid cargo NDJSON compiler-message line.
452    // Uses printf '%s\n' to avoid the shell interpreting escape sequences
453    // (echo interprets \n in the rendered field as a literal newline, splitting
454    // the JSON across lines). The rendered field omits \n since the parser
455    // does not use it.
456    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    // =========================================================================
488    // Test 4: Parse error captured
489    // =========================================================================
490
491    #[test]
492    fn test_run_tool_parse_error_captured() {
493        // echo outputs garbage that the cargo-audit parser can't parse
494        // (cargo-audit expects valid JSON, not NDJSON)
495        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", // expects a single JSON object
501            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    // =========================================================================
516    // Test 5: Parallel failure isolation
517    // =========================================================================
518
519    #[test]
520    fn test_run_tools_parallel_failure_isolation() {
521        let runner = ToolRunner::new(10);
522
523        // Tool A succeeds: echo empty string -> cargo parser returns Ok(vec![])
524        let tool_a = make_tool(
525            "tool-a",
526            "echo",
527            &[""],
528            "cargo",
529            ToolCategory::Linter,
530        );
531
532        // Tool B fails: nonexistent binary
533        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    // =========================================================================
556    // Test 6: Parallel deterministic order
557    // =========================================================================
558
559    #[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    // =========================================================================
607    // Test 7: Sequential for single tool
608    // =========================================================================
609
610    #[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    // =========================================================================
631    // Test 8: Non-zero exit with parseable output = success
632    // =========================================================================
633
634    // sh -c command that outputs a valid NDJSON line and exits with code 1
635    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        // Linters exit non-zero when they find issues. That's not a failure.
647        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    // =========================================================================
676    // Test 9: Empty tool list
677    // =========================================================================
678
679    #[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    // =========================================================================
690    // Test 10: Success with echo (no findings)
691    // =========================================================================
692
693    #[test]
694    fn test_run_tool_success_echo_empty_output() {
695        // echo "" produces effectively empty output, cargo parser returns empty vec
696        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    // =========================================================================
715    // Test 11: Duration is tracked
716    // =========================================================================
717
718    #[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        // Duration should be positive (process did run)
732        // We can't assert exact timing but it shouldn't be huge
733        assert!(
734            result.duration_ms < 5000,
735            "echo should complete in well under 5s, got {}ms",
736            result.duration_ms
737        );
738    }
739
740    // =========================================================================
741    // Test 12: Category preserved in result
742    // =========================================================================
743
744    #[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    // =========================================================================
765    // Test 13: Build-finished only output (valid JSON, no findings)
766    // =========================================================================
767
768    #[test]
769    fn test_run_tool_build_finished_only() {
770        // cargo outputs build-finished at the end; that's not a compiler-message
771        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    // =========================================================================
788    // Test 14: Unknown parser name
789    // =========================================================================
790
791    #[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    // =========================================================================
814    // Test 15: Multiple findings have tool name injected
815    // =========================================================================
816
817    // sh -c command that outputs two NDJSON lines + build-finished
818    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    // =========================================================================
853    // Test: F1 - Stdout/stderr truncation constant exists
854    // =========================================================================
855
856    #[test]
857    fn test_max_output_bytes_constant_exists() {
858        // F1: There should be a safety limit on captured stdout/stderr
859        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        // F1: When a tool produces output larger than MAX_OUTPUT_BYTES,
874        // the output should be truncated and parsing should still work.
875        let runner = ToolRunner::new(10);
876
877        // Generate output larger than MAX_OUTPUT_BYTES by repeating a valid
878        // NDJSON line many times. Each line is ~500 bytes, so we need
879        // MAX_OUTPUT_BYTES / 500 + 1 lines to exceed the limit.
880        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        // Build a shell command that prints the line N times
885        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            // We need to leak the strings for 'static lifetime
894            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        // The tool should still succeed (output is truncated but parseable)
902        assert!(result.success, "should succeed even with truncated output, error: {:?}", result.error);
903    }
904
905    // =========================================================================
906    // Test: F4 - Thread panic converts to error result
907    // =========================================================================
908
909    #[test]
910    fn test_thread_panic_does_not_propagate() {
911        // F4: If a tool thread panics, the parent should NOT panic.
912        // Instead, it should produce a ToolResult with success=false.
913        // We can't easily trigger a panic inside run_tool, but we can
914        // verify the structural expectation: run_tools_parallel should
915        // return results for all tools even if one has issues.
916        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        // Tool with a binary that will fail to spawn
927        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        // Both tools should produce results (no panic propagation)
939        assert_eq!(results.len(), 2, "should have results for both tools");
940    }
941}