Skip to main content

rumdl_lib/code_block_tools/
executor.rs

1//! Tool execution engine for running external formatters and linters.
2//!
3//! This module handles the actual execution of external tools via stdin/stdout,
4//! with timeout support and lazy tool availability checking.
5
6use super::config::ToolDefinition;
7use super::wait;
8use std::collections::HashMap;
9use std::io::{Read, Write};
10use std::path::PathBuf;
11use std::process::{Command, Stdio};
12use std::sync::{Arc, LazyLock, Mutex};
13use std::thread;
14use std::time::{Duration, SystemTime};
15
16/// Ignores `SIGPIPE` process-wide for as long as any instance is alive.
17///
18/// rumdl restores the default disposition of `SIGPIPE` at startup so that piping its
19/// own output into `head` ends the run quietly. Writing to a tool that has already
20/// exited raises the same signal, which kills rumdl mid-run instead of producing the
21/// `BrokenPipe` error the stdin write is written to tolerate.
22///
23/// The disposition has to be changed for the whole process, not masked for the writing
24/// thread: macOS raises a pipe-write `SIGPIPE` against the process rather than against
25/// the thread that wrote, so a thread mask only moves the death to whichever other
26/// thread does not block it (observed landing in a pipe-reader thread). Ignoring it
27/// discards the signal where it is raised.
28///
29/// The window is the write alone, and concurrent tool executions are counted so that
30/// the first to finish does not restore the disposition while another is still writing.
31#[cfg(unix)]
32struct SigpipeIgnored;
33
34#[cfg(unix)]
35static SIGPIPE_IGNORED: Mutex<(usize, libc::sighandler_t)> = Mutex::new((0, 0));
36
37#[cfg(unix)]
38impl SigpipeIgnored {
39    fn new() -> Self {
40        let mut state = SIGPIPE_IGNORED
41            .lock()
42            .unwrap_or_else(std::sync::PoisonError::into_inner);
43        if state.0 == 0 {
44            // SAFETY: `signal` with `SIG_IGN` is async-signal-safe and the previous
45            // disposition is restored in `Drop`.
46            state.1 = unsafe { libc::signal(libc::SIGPIPE, libc::SIG_IGN) };
47        }
48        state.0 += 1;
49        Self
50    }
51}
52
53#[cfg(unix)]
54impl Drop for SigpipeIgnored {
55    fn drop(&mut self) {
56        let mut state = SIGPIPE_IGNORED
57            .lock()
58            .unwrap_or_else(std::sync::PoisonError::into_inner);
59        state.0 -= 1;
60        if state.0 == 0 {
61            // SAFETY: as in `new`. Restores whatever disposition was in place, which is
62            // the default for the CLI and `SIG_IGN` for a library caller.
63            unsafe {
64                libc::signal(libc::SIGPIPE, state.1);
65            }
66        }
67    }
68}
69
70/// `bun fmt` prefers a package.json script over the built-in formatter.
71/// Reject that ambiguity before spawning a code-block formatter: script output
72/// is not formatted code, and the script may write unrelated project files.
73fn check_bun_fmt_scripts(start: &std::path::Path) -> Result<(), String> {
74    for directory in start.ancestors() {
75        // Bun also dispatches local entry points and package-installed binaries.
76        // Check the ancestor locations conservatively so invoking from a docs
77        // subdirectory cannot bypass a collision at the package root.
78        let mut entries = vec![directory.join("fmt"), directory.join("fmt/package.json")];
79        for extension in ["tsx", "ts", "jsx", "cts", "cjs", "js", "mjs", "mts"] {
80            entries.push(directory.join(format!("fmt.{extension}")));
81            entries.push(directory.join(format!("fmt/index.{extension}")));
82        }
83        for suffix in ["", ".bunx", ".exe", ".cmd"] {
84            entries.push(directory.join(format!("node_modules/.bin/fmt{suffix}")));
85        }
86        if let Some(entry) = entries.into_iter().find(|entry| entry.is_file()) {
87            return Err(format!(
88                "bun fmt would run a project entry point at {} instead of the built-in formatter; rename that entry point before using bun-fmt",
89                entry.display()
90            ));
91        }
92        let manifest = directory.join("package.json");
93        let source = match std::fs::read_to_string(&manifest) {
94            Ok(source) => source,
95            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
96            Err(error) => {
97                return Err(format!(
98                    "Cannot inspect {} before running bun fmt: {error}",
99                    manifest.display()
100                ));
101            }
102        };
103        let package: serde_json::Value = serde_json::from_str(&source)
104            .map_err(|error| format!("Cannot inspect {} before running bun fmt: {error}", manifest.display()))?;
105        if package.get("scripts").and_then(|scripts| scripts.get("fmt")).is_some() {
106            return Err(format!(
107                "bun fmt would run the package.json fmt script in {} instead of the built-in formatter; rename that script before using bun-fmt",
108                manifest.display()
109            ));
110        }
111    }
112    Ok(())
113}
114
115/// Timeouts of one tool that end further attempts at it.
116///
117/// A tool that hangs does so for every block it is handed, and each attempt costs the
118/// whole timeout. Three is enough to tell a hanging tool from one that is merely slow on
119/// an occasional large block.
120const TIMEOUT_LIMIT: u32 = 3;
121
122/// Timeout tallies keyed by tool name, shared by every executor in the process.
123///
124/// The tally has to outlive one executor: a fresh executor is built per file, so
125/// per-instance state would forget what the previous file just learned and every file
126/// would pay the timeout over again. A tool that exits on its own clears its own tally,
127/// so a single slow block never disables anything.
128static TIMEOUT_COUNTS: LazyLock<Arc<Mutex<HashMap<String, u32>>>> =
129    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
130
131// Version probes are shared across files, which each construct an executor.
132// Resolve the binary and include its identity so PATH changes and replacements
133// do not inherit an answer for a different installation.
134type CheckCapabilityKey = (String, PathBuf, Option<SystemTime>, u64);
135static CHECK_CAPABILITIES: LazyLock<Mutex<HashMap<CheckCapabilityKey, bool>>> =
136    LazyLock::new(|| Mutex::new(HashMap::new()));
137
138/// A native check verdict or formatted content for exact comparison.
139pub(crate) enum FormatCheckOutput {
140    Native { changed: bool },
141    Formatted(String),
142}
143
144/// Result of executing a tool.
145#[derive(Debug, Clone)]
146pub struct ToolOutput {
147    /// Standard output from the tool.
148    pub stdout: String,
149    /// Standard error from the tool.
150    pub stderr: String,
151    /// Exit code (0 typically means success).
152    pub exit_code: i32,
153    /// Whether the tool executed successfully (exit code 0).
154    pub success: bool,
155}
156
157/// Error during tool execution.
158#[derive(Debug, Clone)]
159pub enum ExecutorError {
160    /// Tool binary not found in allowed lookup locations.
161    ToolNotFound { tool: String },
162    /// Tool execution failed.
163    ExecutionFailed { tool: String, message: String },
164    /// Tool execution timed out.
165    Timeout { tool: String, timeout_ms: u64 },
166    /// Tool skipped without being run, having already timed out repeatedly.
167    RepeatedTimeouts {
168        tool: String,
169        timeout_ms: u64,
170        timeouts: u32,
171    },
172    /// I/O error during execution.
173    IoError { message: String },
174}
175
176impl std::fmt::Display for ExecutorError {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        match self {
179            Self::ToolNotFound { tool } => {
180                write!(f, "Tool '{tool}' not found in allowed lookup locations")
181            }
182            Self::ExecutionFailed { tool, message } => {
183                write!(f, "Tool '{tool}' failed: {message}")
184            }
185            Self::Timeout { tool, timeout_ms } => {
186                write!(f, "Tool '{tool}' timed out after {timeout_ms}ms")
187            }
188            Self::RepeatedTimeouts {
189                tool,
190                timeout_ms,
191                timeouts,
192            } => {
193                write!(
194                    f,
195                    "Tool '{tool}' skipped after timing out {timeouts} times at {timeout_ms}ms; a tool that never exits is usually not reading its stdin"
196                )
197            }
198            Self::IoError { message } => {
199                write!(f, "I/O error: {message}")
200            }
201        }
202    }
203}
204
205impl std::error::Error for ExecutorError {}
206
207/// Executor for running external tools.
208///
209/// Caches tool availability checks for efficiency.
210pub struct ToolExecutor {
211    /// Cache of tool availability checks (tool name -> available).
212    tool_cache: Arc<Mutex<HashMap<String, bool>>>,
213    /// Timeouts recorded per tool since it last exited on its own.
214    timeout_counts: Arc<Mutex<HashMap<String, u32>>>,
215    /// Default timeout in milliseconds.
216    default_timeout_ms: u64,
217    binary_preferences: std::collections::BTreeMap<String, super::config::BinaryPreference>,
218    project_root: std::path::PathBuf,
219    run_state: Option<Arc<super::run_state::RunState>>,
220}
221
222impl ToolExecutor {
223    /// Create a new executor with the given default timeout.
224    ///
225    /// Timeouts are tallied process-wide, so a tool that hangs is attempted a bounded
226    /// number of times across every file of a run rather than once per file.
227    pub fn new(default_timeout_ms: u64) -> Self {
228        Self {
229            tool_cache: Arc::new(Mutex::new(HashMap::new())),
230            timeout_counts: Arc::clone(&TIMEOUT_COUNTS),
231            default_timeout_ms,
232            binary_preferences: std::collections::BTreeMap::new(),
233            project_root: std::env::current_dir().unwrap_or_default(),
234            run_state: None,
235        }
236    }
237
238    /// Create an executor that tallies timeouts only for itself.
239    ///
240    /// For callers that must not inherit or contribute to the process-wide tally, such
241    /// as tests, where one test's hanging tool would otherwise decide whether another
242    /// test's tool is run at all.
243    pub fn isolated(default_timeout_ms: u64) -> Self {
244        Self {
245            tool_cache: Arc::new(Mutex::new(HashMap::new())),
246            timeout_counts: Arc::new(Mutex::new(HashMap::new())),
247            default_timeout_ms,
248            binary_preferences: std::collections::BTreeMap::new(),
249            project_root: std::env::current_dir().unwrap_or_default(),
250            run_state: None,
251        }
252    }
253
254    /// Use the document's project for installed-binary discovery.
255    pub fn with_binary_resolution(
256        mut self,
257        preferences: std::collections::BTreeMap<String, super::config::BinaryPreference>,
258        root: std::path::PathBuf,
259    ) -> Self {
260        self.binary_preferences = preferences;
261        self.project_root = root;
262        self.tool_cache.lock().unwrap().clear();
263        self
264    }
265
266    pub fn with_run_state(mut self, state: Arc<super::run_state::RunState>) -> Self {
267        self.run_state = Some(state);
268        self
269    }
270
271    pub fn resolve_binary(&self, binary: &str) -> Option<std::path::PathBuf> {
272        super::binary::resolve(
273            binary,
274            &self.binary_preferences,
275            &self.project_root,
276            std::env::var_os("PATH").as_deref(),
277        )
278    }
279
280    /// Timeouts recorded for a tool since it last exited on its own.
281    fn timeout_key(&self, tool_name: &str) -> String {
282        self.resolve_binary(tool_name).map_or_else(
283            || tool_name.into(),
284            |path| {
285                std::fs::canonicalize(&path)
286                    .unwrap_or(path)
287                    .to_string_lossy()
288                    .into_owned()
289            },
290        )
291    }
292
293    fn timeout_count(&self, tool_name: &str) -> u32 {
294        self.timeout_counts
295            .lock()
296            .unwrap()
297            .get(&self.timeout_key(tool_name))
298            .copied()
299            .unwrap_or(0)
300    }
301
302    /// Record that a tool had to be killed at its timeout.
303    fn record_timeout(&self, tool_name: &str) {
304        *self
305            .timeout_counts
306            .lock()
307            .unwrap()
308            .entry(self.timeout_key(tool_name))
309            .or_insert(0) += 1;
310    }
311
312    /// Forget a tool's timeouts, after it exited without needing to be killed.
313    fn clear_timeouts(&self, tool_name: &str) {
314        self.timeout_counts.lock().unwrap().remove(&self.timeout_key(tool_name));
315    }
316
317    /// Check if a tool is available (lazy, cached).
318    pub fn is_tool_available(&self, tool_name: &str) -> bool {
319        // Check cache first
320        {
321            let cache = self.tool_cache.lock().unwrap();
322            if let Some(&available) = cache.get(tool_name) {
323                return available;
324            }
325        }
326
327        // Resolved in-process the way the spawn itself would resolve it, so the
328        // answer does not depend on a `which`/`where` binary being installed.
329        let available = self.check_tool_exists(tool_name);
330
331        // Cache the result
332        {
333            let mut cache = self.tool_cache.lock().unwrap();
334            cache.insert(tool_name.to_string(), available);
335        }
336
337        available
338    }
339
340    /// Check if a tool binary exists where `Command::new` would look for it.
341    fn check_tool_exists(&self, tool_name: &str) -> bool {
342        self.resolve_binary(tool_name).is_some()
343    }
344
345    /// Execute a tool with the given input.
346    ///
347    /// # Arguments
348    /// * `tool_def` - Tool definition with command and arguments
349    /// * `input` - Content to pass via stdin
350    /// * `is_format_mode` - Whether to use format_args (true) or lint_args (false)
351    /// * `timeout_ms` - Optional timeout override
352    ///
353    /// # Returns
354    /// Tool output on success, or an error.
355    pub fn execute(
356        &self,
357        tool_def: &ToolDefinition,
358        input: &str,
359        is_format_mode: bool,
360        timeout_ms: Option<u64>,
361    ) -> Result<ToolOutput, ExecutorError> {
362        if tool_def.command.is_empty() {
363            return Err(ExecutorError::ExecutionFailed {
364                tool: "unknown".to_string(),
365                message: "Empty command".to_string(),
366            });
367        }
368
369        let tool_name = &tool_def.command[0];
370
371        if tool_name == "bun" && tool_def.command.get(1).is_some_and(|arg| arg == "fmt") {
372            let cwd = std::env::current_dir().map_err(|error| ExecutorError::IoError {
373                message: error.to_string(),
374            })?;
375            check_bun_fmt_scripts(&cwd).map_err(|message| ExecutorError::ExecutionFailed {
376                tool: tool_name.clone(),
377                message,
378            })?;
379        }
380
381        // Check tool availability (lazy, cached)
382        if !self.is_tool_available(tool_name) {
383            return Err(ExecutorError::ToolNotFound {
384                tool: tool_name.clone(),
385            });
386        }
387
388        // A tool that has hung this many times will hang again, and every further
389        // attempt costs the full timeout. Report it per block, but stop paying for it.
390        // Files are processed in parallel, so attempts already in flight when the limit
391        // is reached still run: the ceiling is the limit plus the worker count, which is
392        // a constant, rather than one timeout per code block in the run.
393        let effective_timeout_ms = timeout_ms.unwrap_or(self.default_timeout_ms);
394        let timeouts = self.timeout_count(tool_name);
395        if timeouts >= TIMEOUT_LIMIT {
396            return Err(ExecutorError::RepeatedTimeouts {
397                tool: tool_name.clone(),
398                timeout_ms: effective_timeout_ms,
399                timeouts,
400            });
401        }
402
403        // Build command
404        let resolved_binary = self
405            .resolve_binary(tool_name)
406            .ok_or_else(|| ExecutorError::ToolNotFound {
407                tool: tool_name.clone(),
408            })?;
409        let mut cmd = Command::new(resolved_binary);
410
411        // Add base arguments
412        if tool_def.command.len() > 1 {
413            cmd.args(&tool_def.command[1..]);
414        }
415
416        // Add mode-specific arguments
417        let extra_args = if is_format_mode {
418            &tool_def.format_args
419        } else {
420            &tool_def.lint_args
421        };
422        if !extra_args.is_empty() {
423            cmd.args(extra_args);
424        }
425
426        // Configure stdin/stdout
427        if tool_def.stdin {
428            cmd.stdin(Stdio::piped());
429        }
430        cmd.stdout(Stdio::piped());
431        cmd.stderr(Stdio::piped());
432
433        // Spawn process
434        let mut child = cmd.spawn().map_err(|e| ExecutorError::IoError {
435            message: format!("Failed to spawn '{tool_name}': {e}"),
436        })?;
437
438        if let Some(state) = &self.run_state {
439            state.record_execution();
440        }
441
442        let mut stdout_handle = child
443            .stdout
444            .take()
445            .map(|stdout| thread::spawn(move || read_pipe_to_string(stdout, true)));
446        let mut stderr_handle = child
447            .stderr
448            .take()
449            .map(|stderr| thread::spawn(move || read_pipe_to_string(stderr, false)));
450
451        // Write stdin if required.
452        // BrokenPipe is ignored: the tool may exit before consuming all input
453        // (e.g., `true` or a linter that validates without reading fully). On Unix that
454        // write raises SIGPIPE, so the signal is blocked for the duration or the error
455        // below is never reached.
456        if tool_def.stdin
457            && let Some(mut stdin) = child.stdin.take()
458        {
459            #[cfg(unix)]
460            let _sigpipe = SigpipeIgnored::new();
461
462            if let Err(e) = stdin.write_all(input.as_bytes())
463                && e.kind() != std::io::ErrorKind::BrokenPipe
464            {
465                return Err(ExecutorError::IoError {
466                    message: format!("Failed to write to stdin: {e}"),
467                });
468            }
469        }
470
471        // Wait for completion with timeout
472        let timeout = Duration::from_millis(effective_timeout_ms);
473        let status = if timeout.is_zero() {
474            child.wait().map_err(|e| ExecutorError::IoError {
475                message: format!("Failed to wait for '{tool_name}': {e}"),
476            })?
477        } else {
478            let waited = wait::wait_timeout(&mut child, timeout).map_err(|e| ExecutorError::IoError {
479                message: format!("Failed to wait for '{tool_name}': {e}"),
480            })?;
481            match waited {
482                Some(status) => status,
483                None => {
484                    let _ = child.kill();
485                    let _ = child.wait();
486                    // The reader threads are deliberately abandoned rather than joined.
487                    // `read_to_end` returns only once every write end of the pipe is
488                    // closed, and a killed tool can leave a descendant holding one, so
489                    // joining here waits on exactly the process the timeout exists to
490                    // bound. Each thread ends by itself once the pipe finally closes.
491                    drop(stdout_handle.take());
492                    drop(stderr_handle.take());
493                    self.record_timeout(tool_name);
494                    return Err(ExecutorError::Timeout {
495                        tool: tool_name.clone(),
496                        timeout_ms: timeout.as_millis() as u64,
497                    });
498                }
499            }
500        };
501
502        // The tool exited on its own, so whatever made earlier runs hang is over.
503        self.clear_timeouts(tool_name);
504
505        let stdout = join_reader(stdout_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
506        let stderr = join_reader(stderr_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
507        let exit_code = status.code().unwrap_or(-1);
508
509        Ok(ToolOutput {
510            stdout,
511            stderr,
512            exit_code,
513            success: status.success(),
514        })
515    }
516
517    /// Prefer reliable stdin checks for the explicit djLint and Shuck modes.
518    /// Unknown and older versions retain the formatter-comparison path.
519    pub(crate) fn format_check(
520        &self,
521        tool_def: &ToolDefinition,
522        native_check: Option<&ToolDefinition>,
523        input: &str,
524        timeout_ms: Option<u64>,
525    ) -> Result<FormatCheckOutput, ExecutorError> {
526        let Some(check) = native_check.filter(|check| self.native_check_available(check, timeout_ms)) else {
527            return self
528                .format(tool_def, input, timeout_ms)
529                .map(FormatCheckOutput::Formatted);
530        };
531        let output = self.execute(check, input, false, timeout_ms)?;
532        if output.success {
533            // djLint prints formatted stdin even for clean checks. Compare it
534            // when available, which also protects a long-lived process whose
535            // version-manager shim switched back to an older implementation.
536            return Ok(if check.command[0] == "djlint" && !output.stdout.trim().is_empty() {
537                FormatCheckOutput::Formatted(output.stdout)
538            } else {
539                FormatCheckOutput::Native { changed: false }
540            });
541        }
542        // djLint returns the formatted stdin on stdout even with --check. Its
543        // crashes also exit 1, so require changed content and no error on stderr.
544        // Shuck's formatting-needed exit is silent; parse failures exit 2.
545        let dirty = output.exit_code == 1
546            && output.stderr.trim().is_empty()
547            && if check.command[0] == "shuck" {
548                output.stdout.trim().is_empty()
549            } else {
550                !output.stdout.trim().is_empty() && output.stdout != input
551            };
552        if dirty {
553            Ok(if check.command[0] == "djlint" {
554                FormatCheckOutput::Formatted(output.stdout)
555            } else {
556                FormatCheckOutput::Native { changed: true }
557            })
558        } else {
559            Err(ExecutorError::ExecutionFailed {
560                tool: check.command[0].clone(),
561                message: format!(
562                    "Exit code {}: {}",
563                    output.exit_code,
564                    if output.stderr.trim().is_empty() {
565                        &output.stdout
566                    } else {
567                        &output.stderr
568                    }
569                ),
570            })
571        }
572    }
573
574    fn native_check_available(&self, tool_def: &ToolDefinition, timeout_ms: Option<u64>) -> bool {
575        let Some(tool) = tool_def.command.first() else {
576            return false;
577        };
578        if !matches!(tool.as_str(), "djlint" | "shuck") {
579            return false;
580        }
581        let Some(path) = self.resolve_binary(tool).and_then(|path| path.canonicalize().ok()) else {
582            return false;
583        };
584        let Ok(metadata) = path.metadata() else {
585            return false;
586        };
587        let key = (tool.clone(), path, metadata.modified().ok(), metadata.len());
588        let mut capabilities = CHECK_CAPABILITIES
589            .lock()
590            .unwrap_or_else(std::sync::PoisonError::into_inner);
591        if let Some(available) = capabilities.get(&key) {
592            return *available;
593        }
594        let mut probe = tool_def.clone();
595        probe.command = vec![tool.clone(), "--version".to_string()];
596        probe.stdin = false;
597        probe.lint_args.clear();
598        probe.format_args.clear();
599        let available = self
600            .execute(&probe, "", false, timeout_ms)
601            .is_ok_and(|output| output.success && supports_native_check_version(tool, &output.stdout));
602        capabilities.insert(key, available);
603        available
604    }
605
606    /// Execute a tool for formatting (returns formatted content).
607    ///
608    /// A formatter that succeeds but prints nothing for a non-empty block has
609    /// failed: taking its output would erase the block, and reporting success
610    /// would claim the block was formatted. Typically a linter configured as a
611    /// formatter, which validates its input and prints nothing.
612    pub fn format(
613        &self,
614        tool_def: &ToolDefinition,
615        input: &str,
616        timeout_ms: Option<u64>,
617    ) -> Result<String, ExecutorError> {
618        let output = self.execute(tool_def, input, true, timeout_ms)?;
619
620        if output.success && tool_def.stdout {
621            if output.stdout.trim().is_empty() && !input.trim().is_empty() {
622                return Err(ExecutorError::ExecutionFailed {
623                    tool: tool_def.command.first().cloned().unwrap_or_default(),
624                    message: "Formatter printed no output for a non-empty code block".to_string(),
625                });
626            }
627            Ok(output.stdout)
628        } else if !output.success {
629            let exit_code = output.exit_code;
630            let stderr = &output.stderr;
631            Err(ExecutorError::ExecutionFailed {
632                tool: tool_def.command.first().cloned().unwrap_or_default(),
633                message: format!("Exit code {exit_code}: {stderr}"),
634            })
635        } else {
636            // Tool doesn't output to stdout, which is unusual for a formatter
637            Err(ExecutorError::ExecutionFailed {
638                tool: tool_def.command.first().cloned().unwrap_or_default(),
639                message: "Formatter doesn't output to stdout".to_string(),
640            })
641        }
642    }
643
644    /// Execute a tool for linting (returns diagnostics).
645    pub fn lint(
646        &self,
647        tool_def: &ToolDefinition,
648        input: &str,
649        timeout_ms: Option<u64>,
650    ) -> Result<ToolOutput, ExecutorError> {
651        self.execute(tool_def, input, false, timeout_ms)
652    }
653}
654
655/// These stable releases have verified stdin check semantics. Unknown output,
656/// prereleases, and earlier releases keep comparison rather than guessing.
657fn supports_native_check_version(tool: &str, output: &str) -> bool {
658    let prefix = match tool {
659        "djlint" => "djlint, version ",
660        "shuck" => "shuck ",
661        _ => return false,
662    };
663    let Some(version) = output.trim().strip_prefix(prefix) else {
664        return false;
665    };
666    let parts: Vec<_> = version.split('.').collect();
667    if parts.len() != 3 {
668        return false;
669    }
670    let Ok(parts) = parts
671        .iter()
672        .map(|part| part.parse::<u64>())
673        .collect::<Result<Vec<_>, _>>()
674    else {
675        return false;
676    };
677    let minimum = if tool == "djlint" { [1, 39, 5] } else { [0, 2, 2] };
678    parts.as_slice() >= minimum.as_slice()
679}
680
681fn read_pipe_to_string<R: Read>(mut pipe: R, strict_utf8: bool) -> std::io::Result<String> {
682    let mut buf = Vec::new();
683    pipe.read_to_end(&mut buf)?;
684    if strict_utf8 {
685        String::from_utf8(buf).map_err(|error| {
686            std::io::Error::new(
687                std::io::ErrorKind::InvalidData,
688                format!("Tool stdout is not valid UTF-8: {error}"),
689            )
690        })
691    } else {
692        Ok(String::from_utf8_lossy(&buf).to_string())
693    }
694}
695
696fn join_reader(handle: Option<thread::JoinHandle<std::io::Result<String>>>) -> Result<String, String> {
697    match handle {
698        Some(handle) => match handle.join() {
699            Ok(res) => res.map_err(|e| format!("Failed to read output: {e}")),
700            Err(_) => Err("Output reader thread panicked".to_string()),
701        },
702        None => Ok(String::new()),
703    }
704}
705
706impl Default for ToolExecutor {
707    fn default() -> Self {
708        Self::new(30_000) // 30 seconds default
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use std::time::Instant;
716
717    #[test]
718    fn bun_fmt_rejects_local_entry_points_and_package_binaries() {
719        for entry in ["fmt", "fmt.ts", "fmt/index.js", "node_modules/.bin/fmt"] {
720            let directory = tempfile::tempdir().unwrap();
721            let path = directory.path().join(entry);
722            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
723            std::fs::write(path, "console.log('not formatted code')").unwrap();
724            let error = check_bun_fmt_scripts(directory.path()).unwrap_err();
725            assert!(error.contains("instead of the built-in formatter"), "{entry}: {error}");
726        }
727    }
728
729    #[test]
730    fn bun_fmt_rejects_scripts_in_the_project_or_an_ancestor() {
731        let directory = tempfile::tempdir().unwrap();
732        let nested = directory.path().join("docs");
733        std::fs::create_dir(&nested).unwrap();
734        let manifest = directory.path().join("package.json");
735        std::fs::write(&manifest, r#"{"scripts":{"fmt":"echo not a formatter"}}"#).unwrap();
736        let error = check_bun_fmt_scripts(&nested).unwrap_err();
737        assert!(error.contains("instead of the built-in formatter"));
738        std::fs::write(&manifest, r#"{"scripts":{"format":"prettier ."}}"#).unwrap();
739        assert!(check_bun_fmt_scripts(&nested).is_ok());
740        std::fs::write(&manifest, "invalid json").unwrap();
741        assert!(check_bun_fmt_scripts(&nested).is_err());
742    }
743
744    #[test]
745    fn test_executor_creation() {
746        let executor = ToolExecutor::new(10_000);
747        // Just verify it creates successfully
748        assert_eq!(executor.default_timeout_ms, 10_000);
749    }
750
751    #[test]
752    fn test_tool_not_found() {
753        let executor = ToolExecutor::default();
754        let tool_def = ToolDefinition {
755            command: vec!["nonexistent-tool-xyz123".to_string()],
756            stdin: true,
757            stdout: true,
758            lint_args: vec![],
759            format_args: vec![],
760        };
761
762        let result = executor.execute(&tool_def, "test", false, None);
763        assert!(matches!(result, Err(ExecutorError::ToolNotFound { .. })));
764    }
765
766    #[test]
767    fn test_empty_command() {
768        let executor = ToolExecutor::default();
769        let tool_def = ToolDefinition {
770            command: vec![],
771            stdin: true,
772            stdout: true,
773            lint_args: vec![],
774            format_args: vec![],
775        };
776
777        let result = executor.execute(&tool_def, "test", false, None);
778        assert!(matches!(result, Err(ExecutorError::ExecutionFailed { .. })));
779    }
780
781    #[test]
782    #[cfg(unix)]
783    fn test_execute_cat() {
784        let executor = ToolExecutor::isolated(30_000);
785        let tool_def = ToolDefinition {
786            command: vec!["cat".to_string()],
787            stdin: true,
788            stdout: true,
789            lint_args: vec![],
790            format_args: vec![],
791        };
792
793        let result = executor.execute(&tool_def, "hello world", false, None);
794        let output = result.expect("cat should succeed");
795        assert!(output.success);
796        assert_eq!(output.stdout.trim(), "hello world");
797    }
798
799    #[test]
800    #[cfg(unix)]
801    fn test_timeout() {
802        let executor = ToolExecutor::isolated(5);
803        let tool_def = ToolDefinition {
804            command: vec!["sleep".to_string(), "1".to_string()],
805            stdin: false,
806            stdout: true,
807            lint_args: vec![],
808            format_args: vec![],
809        };
810
811        let result = executor.execute(&tool_def, "", false, Some(5));
812        assert!(matches!(result, Err(ExecutorError::Timeout { .. })));
813    }
814
815    /// A tool's exit has to be noticed when it happens, not at the next tick of a poll
816    /// interval: for a fast formatter or linter that interval is most of the cost of
817    /// running it once per code block. The fastest of several runs is compared, so a
818    /// scheduling delay on a loaded machine cannot fail a correct implementation, while
819    /// a 10ms poll can never produce a run under that interval.
820    #[test]
821    #[cfg(unix)]
822    fn test_performance_exit_is_noticed_without_a_poll_interval() {
823        let executor = ToolExecutor::isolated(30_000);
824        let tool_def = ToolDefinition {
825            command: vec!["true".to_string()],
826            stdin: false,
827            stdout: true,
828            lint_args: vec![],
829            format_args: vec![],
830        };
831
832        let fastest = (0..20)
833            .map(|_| {
834                let started = Instant::now();
835                executor
836                    .execute(&tool_def, "", false, None)
837                    .expect("true should succeed");
838                started.elapsed()
839            })
840            .min()
841            .unwrap();
842        assert!(
843            fastest < Duration::from_millis(8),
844            "fastest of 20 runs of `true` took {fastest:?}"
845        );
846    }
847
848    /// A tool definition whose process outlives its own timeout, and leaves a child
849    /// holding the stdout pipe open after the tool itself is killed.
850    #[cfg(unix)]
851    fn descendant_holds_stdout_tool() -> ToolDefinition {
852        ToolDefinition {
853            command: vec![
854                "sh".to_string(),
855                "-c".to_string(),
856                "sleep 30 & exec sleep 30".to_string(),
857            ],
858            stdin: true,
859            stdout: true,
860            lint_args: vec![],
861            format_args: vec![],
862        }
863    }
864
865    /// The configured timeout has to bound the call even when the killed tool leaves a
866    /// descendant holding the write end of the stdout pipe. Reading that pipe to EOF
867    /// waits for the descendant, which is precisely the process the timeout is for.
868    #[test]
869    #[cfg(unix)]
870    fn test_timeout_bounds_execution_when_a_descendant_holds_stdout() {
871        let executor = ToolExecutor::isolated(200);
872        let (tx, rx) = std::sync::mpsc::channel();
873        thread::spawn(move || {
874            let started = Instant::now();
875            let result = executor.execute(&descendant_holds_stdout_tool(), "input", true, Some(200));
876            let _ = tx.send((started.elapsed(), result));
877        });
878
879        // Generous next to the 200ms timeout, and far below the 30s the descendant
880        // lives for, so this only fires if the call waited on the descendant.
881        let (elapsed, result) = rx
882            .recv_timeout(Duration::from_secs(10))
883            .expect("execute() did not return: the timeout bounded nothing");
884        assert!(
885            matches!(result, Err(ExecutorError::Timeout { .. })),
886            "expected a timeout, got {result:?}"
887        );
888        assert!(elapsed < Duration::from_secs(10), "execute() took {elapsed:?}");
889    }
890
891    /// A hanging tool costs its whole timeout every time it is invoked, so a run over
892    /// many code blocks must stop invoking it rather than pay that repeatedly.
893    #[test]
894    #[cfg(unix)]
895    fn test_a_hanging_tool_is_skipped_after_repeated_timeouts() {
896        let executor = ToolExecutor::isolated(50);
897        let tool_def = ToolDefinition {
898            command: vec!["sh".to_string(), "-c".to_string(), "exec sleep 30".to_string()],
899            stdin: true,
900            stdout: true,
901            lint_args: vec![],
902            format_args: vec![],
903        };
904
905        for attempt in 1..=TIMEOUT_LIMIT {
906            let result = executor.execute(&tool_def, "input", true, Some(50));
907            assert!(
908                matches!(result, Err(ExecutorError::Timeout { .. })),
909                "attempt {attempt} should time out, got {result:?}"
910            );
911        }
912
913        let started = Instant::now();
914        let result = executor.execute(&tool_def, "input", true, Some(50));
915        match result {
916            Err(ExecutorError::RepeatedTimeouts {
917                timeouts, timeout_ms, ..
918            }) => {
919                assert_eq!(timeouts, TIMEOUT_LIMIT);
920                assert_eq!(timeout_ms, 50);
921            }
922            other => panic!("expected the tool to be skipped, got {other:?}"),
923        }
924        // Skipping means not spawning it, so this must not cost another timeout.
925        assert!(
926            started.elapsed() < Duration::from_millis(50),
927            "skipping still took {:?}",
928            started.elapsed()
929        );
930    }
931
932    /// One slow block must not disable a tool for the rest of the run, so exiting on its
933    /// own clears whatever a tool accumulated before.
934    #[test]
935    #[cfg(unix)]
936    fn test_exiting_normally_clears_earlier_timeouts() {
937        let executor = ToolExecutor::isolated(50);
938        // Both definitions run through `sh`, which is what the tally is keyed on.
939        let hangs = ToolDefinition {
940            command: vec!["sh".to_string(), "-c".to_string(), "exec sleep 30".to_string()],
941            stdin: true,
942            stdout: true,
943            lint_args: vec![],
944            format_args: vec![],
945        };
946        let exits = ToolDefinition {
947            command: vec!["sh".to_string(), "-c".to_string(), "cat".to_string()],
948            stdin: true,
949            stdout: true,
950            lint_args: vec![],
951            format_args: vec![],
952        };
953
954        for _ in 0..TIMEOUT_LIMIT - 1 {
955            assert!(matches!(
956                executor.execute(&hangs, "input", true, Some(50)),
957                Err(ExecutorError::Timeout { .. })
958            ));
959        }
960        assert_eq!(executor.timeout_count("sh"), TIMEOUT_LIMIT - 1);
961
962        // The 50ms default exists only to make `hangs` time out quickly. A tool that
963        // exits on its own needs room to spawn on a loaded machine, or it times out too.
964        let output = executor
965            .execute(&exits, "hello", true, Some(10_000))
966            .expect("cat should exit");
967        assert_eq!(output.stdout.trim(), "hello");
968        assert_eq!(executor.timeout_count("sh"), 0, "a clean exit must clear the tally");
969    }
970
971    /// The tally outlives one executor, since a fresh executor is built per file and a
972    /// hanging tool would otherwise be retried from scratch for every file in the run.
973    #[test]
974    fn test_the_shared_tally_carries_across_executors() {
975        // A name no real tool answers to, so this neither reads nor disturbs the tally
976        // of any tool another test in this process may be running.
977        let key = "rumdl-test-only-shared-tally-probe";
978        let first = ToolExecutor::new(50);
979        let second = ToolExecutor::new(50);
980        let alone = ToolExecutor::isolated(50);
981
982        let before = second.timeout_count(key);
983        first.record_timeout(key);
984
985        assert_eq!(
986            second.timeout_count(key),
987            before + 1,
988            "executors built for different files must share one tally"
989        );
990        assert_eq!(alone.timeout_count(key), 0, "an isolated executor keeps its own tally");
991
992        first.clear_timeouts(key);
993        assert_eq!(second.timeout_count(key), 0);
994    }
995}
996
997#[cfg(test)]
998mod native_check_version_tests {
999    use super::supports_native_check_version;
1000
1001    #[test]
1002    fn only_recognized_stable_versions_with_verified_stdin_support_are_admitted() {
1003        for (tool, version, expected) in [
1004            ("djlint", "djlint, version 1.39.4", false),
1005            ("djlint", "djlint, version 1.39.5", true),
1006            ("djlint", "djlint, version 1.100.0", true),
1007            ("shuck", "shuck 0.2.1", false),
1008            ("shuck", "shuck 0.2.2", true),
1009            ("shuck", "shuck 0.10.0", true),
1010            ("djlint", "djlint, version 1.46.4rc1", false),
1011            ("djlint", "djlint, version 1.39.5.post1", false),
1012            ("shuck", "shuck 0.2.3-beta.1", false),
1013            ("shuck", "shuck v0.2.2", false),
1014            ("shuck", "shuck 0.2.2+build", false),
1015            ("shuck", "shuck 0.2", false),
1016            ("shuck", "shuck 0.2.2.1", false),
1017            ("djlint", "shuck 1.46.4", false),
1018            ("djlint", "", false),
1019        ] {
1020            assert_eq!(supports_native_check_version(tool, version), expected, "{version}");
1021        }
1022    }
1023}