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    pub(crate) fn project_root(&self) -> &std::path::Path {
281        &self.project_root
282    }
283
284    /// Timeouts recorded for a tool since it last exited on its own.
285    fn timeout_key(&self, tool_name: &str) -> String {
286        self.resolve_binary(tool_name).map_or_else(
287            || tool_name.into(),
288            |path| {
289                std::fs::canonicalize(&path)
290                    .unwrap_or(path)
291                    .to_string_lossy()
292                    .into_owned()
293            },
294        )
295    }
296
297    fn timeout_count(&self, tool_name: &str) -> u32 {
298        self.timeout_counts
299            .lock()
300            .unwrap()
301            .get(&self.timeout_key(tool_name))
302            .copied()
303            .unwrap_or(0)
304    }
305
306    /// Record that a tool had to be killed at its timeout.
307    fn record_timeout(&self, tool_name: &str) {
308        *self
309            .timeout_counts
310            .lock()
311            .unwrap()
312            .entry(self.timeout_key(tool_name))
313            .or_insert(0) += 1;
314    }
315
316    /// Forget a tool's timeouts, after it exited without needing to be killed.
317    fn clear_timeouts(&self, tool_name: &str) {
318        self.timeout_counts.lock().unwrap().remove(&self.timeout_key(tool_name));
319    }
320
321    /// Check if a tool is available (lazy, cached).
322    pub fn is_tool_available(&self, tool_name: &str) -> bool {
323        // Check cache first
324        {
325            let cache = self.tool_cache.lock().unwrap();
326            if let Some(&available) = cache.get(tool_name) {
327                return available;
328            }
329        }
330
331        // Resolved in-process the way the spawn itself would resolve it, so the
332        // answer does not depend on a `which`/`where` binary being installed.
333        let available = self.check_tool_exists(tool_name);
334
335        // Cache the result
336        {
337            let mut cache = self.tool_cache.lock().unwrap();
338            cache.insert(tool_name.to_string(), available);
339        }
340
341        available
342    }
343
344    /// Check if a tool binary exists where `Command::new` would look for it.
345    fn check_tool_exists(&self, tool_name: &str) -> bool {
346        self.resolve_binary(tool_name).is_some()
347    }
348
349    /// Execute a tool with the given input.
350    ///
351    /// # Arguments
352    /// * `tool_def` - Tool definition with command and arguments
353    /// * `input` - Content to pass via stdin
354    /// * `is_format_mode` - Whether to use format_args (true) or lint_args (false)
355    /// * `timeout_ms` - Optional timeout override
356    ///
357    /// # Returns
358    /// Tool output on success, or an error.
359    pub fn execute(
360        &self,
361        tool_def: &ToolDefinition,
362        input: &str,
363        is_format_mode: bool,
364        timeout_ms: Option<u64>,
365    ) -> Result<ToolOutput, ExecutorError> {
366        if tool_def.command.is_empty() {
367            return Err(ExecutorError::ExecutionFailed {
368                tool: "unknown".to_string(),
369                message: "Empty command".to_string(),
370            });
371        }
372
373        let tool_name = &tool_def.command[0];
374
375        if tool_name == "bun" && tool_def.command.get(1).is_some_and(|arg| arg == "fmt") {
376            let cwd = std::env::current_dir().map_err(|error| ExecutorError::IoError {
377                message: error.to_string(),
378            })?;
379            check_bun_fmt_scripts(&cwd).map_err(|message| ExecutorError::ExecutionFailed {
380                tool: tool_name.clone(),
381                message,
382            })?;
383        }
384
385        // Check tool availability (lazy, cached)
386        if !self.is_tool_available(tool_name) {
387            return Err(ExecutorError::ToolNotFound {
388                tool: tool_name.clone(),
389            });
390        }
391
392        // A tool that has hung this many times will hang again, and every further
393        // attempt costs the full timeout. Report it per block, but stop paying for it.
394        // Files are processed in parallel, so attempts already in flight when the limit
395        // is reached still run: the ceiling is the limit plus the worker count, which is
396        // a constant, rather than one timeout per code block in the run.
397        let effective_timeout_ms = timeout_ms.unwrap_or(self.default_timeout_ms);
398        let timeouts = self.timeout_count(tool_name);
399        if timeouts >= TIMEOUT_LIMIT {
400            return Err(ExecutorError::RepeatedTimeouts {
401                tool: tool_name.clone(),
402                timeout_ms: effective_timeout_ms,
403                timeouts,
404            });
405        }
406
407        // Build command
408        let resolved_binary = self
409            .resolve_binary(tool_name)
410            .ok_or_else(|| ExecutorError::ToolNotFound {
411                tool: tool_name.clone(),
412            })?;
413        let mut cmd = Command::new(resolved_binary);
414
415        // Add base arguments
416        if tool_def.command.len() > 1 {
417            cmd.args(&tool_def.command[1..]);
418        }
419
420        // Add mode-specific arguments
421        let extra_args = if is_format_mode {
422            &tool_def.format_args
423        } else {
424            &tool_def.lint_args
425        };
426        if !extra_args.is_empty() {
427            cmd.args(extra_args);
428        }
429
430        // Configure stdin/stdout
431        if tool_def.stdin {
432            cmd.stdin(Stdio::piped());
433        }
434        cmd.stdout(Stdio::piped());
435        cmd.stderr(Stdio::piped());
436
437        // Spawn process
438        let mut child = cmd.spawn().map_err(|e| ExecutorError::IoError {
439            message: format!("Failed to spawn '{tool_name}': {e}"),
440        })?;
441
442        if let Some(state) = &self.run_state {
443            state.record_execution();
444        }
445
446        let mut stdout_handle = child
447            .stdout
448            .take()
449            .map(|stdout| thread::spawn(move || read_pipe_to_string(stdout, true)));
450        let mut stderr_handle = child
451            .stderr
452            .take()
453            .map(|stderr| thread::spawn(move || read_pipe_to_string(stderr, false)));
454
455        // Write stdin if required.
456        // BrokenPipe is ignored: the tool may exit before consuming all input
457        // (e.g., `true` or a linter that validates without reading fully). On Unix that
458        // write raises SIGPIPE, so the signal is blocked for the duration or the error
459        // below is never reached.
460        if tool_def.stdin
461            && let Some(mut stdin) = child.stdin.take()
462        {
463            #[cfg(unix)]
464            let _sigpipe = SigpipeIgnored::new();
465
466            if let Err(e) = stdin.write_all(input.as_bytes())
467                && e.kind() != std::io::ErrorKind::BrokenPipe
468            {
469                return Err(ExecutorError::IoError {
470                    message: format!("Failed to write to stdin: {e}"),
471                });
472            }
473        }
474
475        // Wait for completion with timeout
476        let timeout = Duration::from_millis(effective_timeout_ms);
477        let status = if timeout.is_zero() {
478            child.wait().map_err(|e| ExecutorError::IoError {
479                message: format!("Failed to wait for '{tool_name}': {e}"),
480            })?
481        } else {
482            let waited = wait::wait_timeout(&mut child, timeout).map_err(|e| ExecutorError::IoError {
483                message: format!("Failed to wait for '{tool_name}': {e}"),
484            })?;
485            match waited {
486                Some(status) => status,
487                None => {
488                    let _ = child.kill();
489                    let _ = child.wait();
490                    // The reader threads are deliberately abandoned rather than joined.
491                    // `read_to_end` returns only once every write end of the pipe is
492                    // closed, and a killed tool can leave a descendant holding one, so
493                    // joining here waits on exactly the process the timeout exists to
494                    // bound. Each thread ends by itself once the pipe finally closes.
495                    drop(stdout_handle.take());
496                    drop(stderr_handle.take());
497                    self.record_timeout(tool_name);
498                    return Err(ExecutorError::Timeout {
499                        tool: tool_name.clone(),
500                        timeout_ms: timeout.as_millis() as u64,
501                    });
502                }
503            }
504        };
505
506        // The tool exited on its own, so whatever made earlier runs hang is over.
507        self.clear_timeouts(tool_name);
508
509        let stdout = join_reader(stdout_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
510        let stderr = join_reader(stderr_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
511        let exit_code = status.code().unwrap_or(-1);
512
513        Ok(ToolOutput {
514            stdout,
515            stderr,
516            exit_code,
517            success: status.success(),
518        })
519    }
520
521    /// Prefer reliable stdin checks for the explicit djLint and Shuck modes.
522    /// Unknown and older versions retain the formatter-comparison path.
523    pub(crate) fn format_check(
524        &self,
525        tool_def: &ToolDefinition,
526        native_check: Option<&ToolDefinition>,
527        input: &str,
528        timeout_ms: Option<u64>,
529    ) -> Result<FormatCheckOutput, ExecutorError> {
530        let Some(check) = native_check.filter(|check| self.native_check_available(check, timeout_ms)) else {
531            return self
532                .format(tool_def, input, timeout_ms)
533                .map(FormatCheckOutput::Formatted);
534        };
535        let output = self.execute(check, input, false, timeout_ms)?;
536        if output.success {
537            // djLint prints formatted stdin even for clean checks. Compare it
538            // when available, which also protects a long-lived process whose
539            // version-manager shim switched back to an older implementation.
540            return Ok(if check.command[0] == "djlint" && !output.stdout.trim().is_empty() {
541                FormatCheckOutput::Formatted(output.stdout)
542            } else {
543                FormatCheckOutput::Native { changed: false }
544            });
545        }
546        // djLint returns the formatted stdin on stdout even with --check. Its
547        // crashes also exit 1, so require changed content and no error on stderr.
548        // Shuck's formatting-needed exit is silent; parse failures exit 2.
549        let dirty = output.exit_code == 1
550            && output.stderr.trim().is_empty()
551            && if check.command[0] == "shuck" {
552                output.stdout.trim().is_empty()
553            } else {
554                !output.stdout.trim().is_empty() && output.stdout != input
555            };
556        if dirty {
557            Ok(if check.command[0] == "djlint" {
558                FormatCheckOutput::Formatted(output.stdout)
559            } else {
560                FormatCheckOutput::Native { changed: true }
561            })
562        } else {
563            Err(ExecutorError::ExecutionFailed {
564                tool: check.command[0].clone(),
565                message: format!(
566                    "Exit code {}: {}",
567                    output.exit_code,
568                    if output.stderr.trim().is_empty() {
569                        &output.stdout
570                    } else {
571                        &output.stderr
572                    }
573                ),
574            })
575        }
576    }
577
578    fn native_check_available(&self, tool_def: &ToolDefinition, timeout_ms: Option<u64>) -> bool {
579        let Some(tool) = tool_def.command.first() else {
580            return false;
581        };
582        if !matches!(tool.as_str(), "djlint" | "shuck") {
583            return false;
584        }
585        let Some(path) = self.resolve_binary(tool).and_then(|path| path.canonicalize().ok()) else {
586            return false;
587        };
588        let Ok(metadata) = path.metadata() else {
589            return false;
590        };
591        let key = (tool.clone(), path, metadata.modified().ok(), metadata.len());
592        let mut capabilities = CHECK_CAPABILITIES
593            .lock()
594            .unwrap_or_else(std::sync::PoisonError::into_inner);
595        if let Some(available) = capabilities.get(&key) {
596            return *available;
597        }
598        let mut probe = tool_def.clone();
599        probe.command = vec![tool.clone(), "--version".to_string()];
600        probe.stdin = false;
601        probe.lint_args.clear();
602        probe.format_args.clear();
603        let available = self
604            .execute(&probe, "", false, timeout_ms)
605            .is_ok_and(|output| output.success && supports_native_check_version(tool, &output.stdout));
606        capabilities.insert(key, available);
607        available
608    }
609
610    /// Execute a tool for formatting (returns formatted content).
611    ///
612    /// A formatter that succeeds but prints nothing for a non-empty block has
613    /// failed: taking its output would erase the block, and reporting success
614    /// would claim the block was formatted. Typically a linter configured as a
615    /// formatter, which validates its input and prints nothing.
616    pub fn format(
617        &self,
618        tool_def: &ToolDefinition,
619        input: &str,
620        timeout_ms: Option<u64>,
621    ) -> Result<String, ExecutorError> {
622        let output = self.execute(tool_def, input, true, timeout_ms)?;
623
624        if output.success && tool_def.stdout {
625            if output.stdout.trim().is_empty() && !input.trim().is_empty() {
626                return Err(ExecutorError::ExecutionFailed {
627                    tool: tool_def.command.first().cloned().unwrap_or_default(),
628                    message: "Formatter printed no output for a non-empty code block".to_string(),
629                });
630            }
631            Ok(output.stdout)
632        } else if !output.success {
633            let exit_code = output.exit_code;
634            let stderr = &output.stderr;
635            Err(ExecutorError::ExecutionFailed {
636                tool: tool_def.command.first().cloned().unwrap_or_default(),
637                message: format!("Exit code {exit_code}: {stderr}"),
638            })
639        } else {
640            // Tool doesn't output to stdout, which is unusual for a formatter
641            Err(ExecutorError::ExecutionFailed {
642                tool: tool_def.command.first().cloned().unwrap_or_default(),
643                message: "Formatter doesn't output to stdout".to_string(),
644            })
645        }
646    }
647
648    /// Execute a tool for linting (returns diagnostics).
649    pub fn lint(
650        &self,
651        tool_def: &ToolDefinition,
652        input: &str,
653        timeout_ms: Option<u64>,
654    ) -> Result<ToolOutput, ExecutorError> {
655        self.execute(tool_def, input, false, timeout_ms)
656    }
657}
658
659/// These stable releases have verified stdin check semantics. Unknown output,
660/// prereleases, and earlier releases keep comparison rather than guessing.
661fn supports_native_check_version(tool: &str, output: &str) -> bool {
662    let prefix = match tool {
663        "djlint" => "djlint, version ",
664        "shuck" => "shuck ",
665        _ => return false,
666    };
667    let Some(version) = output.trim().strip_prefix(prefix) else {
668        return false;
669    };
670    let parts: Vec<_> = version.split('.').collect();
671    if parts.len() != 3 {
672        return false;
673    }
674    let Ok(parts) = parts
675        .iter()
676        .map(|part| part.parse::<u64>())
677        .collect::<Result<Vec<_>, _>>()
678    else {
679        return false;
680    };
681    let minimum = if tool == "djlint" { [1, 39, 5] } else { [0, 2, 2] };
682    parts.as_slice() >= minimum.as_slice()
683}
684
685fn read_pipe_to_string<R: Read>(mut pipe: R, strict_utf8: bool) -> std::io::Result<String> {
686    let mut buf = Vec::new();
687    pipe.read_to_end(&mut buf)?;
688    if strict_utf8 {
689        String::from_utf8(buf).map_err(|error| {
690            std::io::Error::new(
691                std::io::ErrorKind::InvalidData,
692                format!("Tool stdout is not valid UTF-8: {error}"),
693            )
694        })
695    } else {
696        Ok(String::from_utf8_lossy(&buf).to_string())
697    }
698}
699
700fn join_reader(handle: Option<thread::JoinHandle<std::io::Result<String>>>) -> Result<String, String> {
701    match handle {
702        Some(handle) => match handle.join() {
703            Ok(res) => res.map_err(|e| format!("Failed to read output: {e}")),
704            Err(_) => Err("Output reader thread panicked".to_string()),
705        },
706        None => Ok(String::new()),
707    }
708}
709
710impl Default for ToolExecutor {
711    fn default() -> Self {
712        Self::new(30_000) // 30 seconds default
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use std::time::Instant;
720
721    #[test]
722    fn bun_fmt_rejects_local_entry_points_and_package_binaries() {
723        for entry in ["fmt", "fmt.ts", "fmt/index.js", "node_modules/.bin/fmt"] {
724            let directory = tempfile::tempdir().unwrap();
725            let path = directory.path().join(entry);
726            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
727            std::fs::write(path, "console.log('not formatted code')").unwrap();
728            let error = check_bun_fmt_scripts(directory.path()).unwrap_err();
729            assert!(error.contains("instead of the built-in formatter"), "{entry}: {error}");
730        }
731    }
732
733    #[test]
734    fn bun_fmt_rejects_scripts_in_the_project_or_an_ancestor() {
735        let directory = tempfile::tempdir().unwrap();
736        let nested = directory.path().join("docs");
737        std::fs::create_dir(&nested).unwrap();
738        let manifest = directory.path().join("package.json");
739        std::fs::write(&manifest, r#"{"scripts":{"fmt":"echo not a formatter"}}"#).unwrap();
740        let error = check_bun_fmt_scripts(&nested).unwrap_err();
741        assert!(error.contains("instead of the built-in formatter"));
742        std::fs::write(&manifest, r#"{"scripts":{"format":"prettier ."}}"#).unwrap();
743        assert!(check_bun_fmt_scripts(&nested).is_ok());
744        std::fs::write(&manifest, "invalid json").unwrap();
745        assert!(check_bun_fmt_scripts(&nested).is_err());
746    }
747
748    #[test]
749    fn test_executor_creation() {
750        let executor = ToolExecutor::new(10_000);
751        // Just verify it creates successfully
752        assert_eq!(executor.default_timeout_ms, 10_000);
753    }
754
755    #[test]
756    fn test_tool_not_found() {
757        let executor = ToolExecutor::default();
758        let tool_def = ToolDefinition {
759            command: vec!["nonexistent-tool-xyz123".to_string()],
760            stdin: true,
761            stdout: true,
762            lint_args: vec![],
763            format_args: vec![],
764        };
765
766        let result = executor.execute(&tool_def, "test", false, None);
767        assert!(matches!(result, Err(ExecutorError::ToolNotFound { .. })));
768    }
769
770    #[test]
771    fn test_empty_command() {
772        let executor = ToolExecutor::default();
773        let tool_def = ToolDefinition {
774            command: vec![],
775            stdin: true,
776            stdout: true,
777            lint_args: vec![],
778            format_args: vec![],
779        };
780
781        let result = executor.execute(&tool_def, "test", false, None);
782        assert!(matches!(result, Err(ExecutorError::ExecutionFailed { .. })));
783    }
784
785    #[test]
786    #[cfg(unix)]
787    fn test_execute_cat() {
788        let executor = ToolExecutor::isolated(30_000);
789        let tool_def = ToolDefinition {
790            command: vec!["cat".to_string()],
791            stdin: true,
792            stdout: true,
793            lint_args: vec![],
794            format_args: vec![],
795        };
796
797        let result = executor.execute(&tool_def, "hello world", false, None);
798        let output = result.expect("cat should succeed");
799        assert!(output.success);
800        assert_eq!(output.stdout.trim(), "hello world");
801    }
802
803    #[test]
804    #[cfg(unix)]
805    fn test_timeout() {
806        let executor = ToolExecutor::isolated(5);
807        let tool_def = ToolDefinition {
808            command: vec!["sleep".to_string(), "1".to_string()],
809            stdin: false,
810            stdout: true,
811            lint_args: vec![],
812            format_args: vec![],
813        };
814
815        let result = executor.execute(&tool_def, "", false, Some(5));
816        assert!(matches!(result, Err(ExecutorError::Timeout { .. })));
817    }
818
819    /// A tool's exit has to be noticed when it happens, not at the next tick of a poll
820    /// interval: for a fast formatter or linter that interval is most of the cost of
821    /// running it once per code block. The fastest of several runs is compared, so a
822    /// scheduling delay on a loaded machine cannot fail a correct implementation, while
823    /// a 10ms poll can never produce a run under that interval.
824    #[test]
825    #[cfg(unix)]
826    fn test_performance_exit_is_noticed_without_a_poll_interval() {
827        let executor = ToolExecutor::isolated(30_000);
828        let tool_def = ToolDefinition {
829            command: vec!["true".to_string()],
830            stdin: false,
831            stdout: true,
832            lint_args: vec![],
833            format_args: vec![],
834        };
835
836        let fastest = (0..20)
837            .map(|_| {
838                let started = Instant::now();
839                executor
840                    .execute(&tool_def, "", false, None)
841                    .expect("true should succeed");
842                started.elapsed()
843            })
844            .min()
845            .unwrap();
846        assert!(
847            fastest < Duration::from_millis(8),
848            "fastest of 20 runs of `true` took {fastest:?}"
849        );
850    }
851
852    /// A tool definition whose process outlives its own timeout, and leaves a child
853    /// holding the stdout pipe open after the tool itself is killed.
854    #[cfg(unix)]
855    fn descendant_holds_stdout_tool() -> ToolDefinition {
856        ToolDefinition {
857            command: vec![
858                "sh".to_string(),
859                "-c".to_string(),
860                "sleep 30 & exec sleep 30".to_string(),
861            ],
862            stdin: true,
863            stdout: true,
864            lint_args: vec![],
865            format_args: vec![],
866        }
867    }
868
869    /// The configured timeout has to bound the call even when the killed tool leaves a
870    /// descendant holding the write end of the stdout pipe. Reading that pipe to EOF
871    /// waits for the descendant, which is precisely the process the timeout is for.
872    #[test]
873    #[cfg(unix)]
874    fn test_timeout_bounds_execution_when_a_descendant_holds_stdout() {
875        let executor = ToolExecutor::isolated(200);
876        let (tx, rx) = std::sync::mpsc::channel();
877        thread::spawn(move || {
878            let started = Instant::now();
879            let result = executor.execute(&descendant_holds_stdout_tool(), "input", true, Some(200));
880            let _ = tx.send((started.elapsed(), result));
881        });
882
883        // Generous next to the 200ms timeout, and far below the 30s the descendant
884        // lives for, so this only fires if the call waited on the descendant.
885        let (elapsed, result) = rx
886            .recv_timeout(Duration::from_secs(10))
887            .expect("execute() did not return: the timeout bounded nothing");
888        assert!(
889            matches!(result, Err(ExecutorError::Timeout { .. })),
890            "expected a timeout, got {result:?}"
891        );
892        assert!(elapsed < Duration::from_secs(10), "execute() took {elapsed:?}");
893    }
894
895    /// A hanging tool costs its whole timeout every time it is invoked, so a run over
896    /// many code blocks must stop invoking it rather than pay that repeatedly.
897    #[test]
898    #[cfg(unix)]
899    fn test_a_hanging_tool_is_skipped_after_repeated_timeouts() {
900        let executor = ToolExecutor::isolated(50);
901        let tool_def = ToolDefinition {
902            command: vec!["sh".to_string(), "-c".to_string(), "exec sleep 30".to_string()],
903            stdin: true,
904            stdout: true,
905            lint_args: vec![],
906            format_args: vec![],
907        };
908
909        for attempt in 1..=TIMEOUT_LIMIT {
910            let result = executor.execute(&tool_def, "input", true, Some(50));
911            assert!(
912                matches!(result, Err(ExecutorError::Timeout { .. })),
913                "attempt {attempt} should time out, got {result:?}"
914            );
915        }
916
917        let started = Instant::now();
918        let result = executor.execute(&tool_def, "input", true, Some(50));
919        match result {
920            Err(ExecutorError::RepeatedTimeouts {
921                timeouts, timeout_ms, ..
922            }) => {
923                assert_eq!(timeouts, TIMEOUT_LIMIT);
924                assert_eq!(timeout_ms, 50);
925            }
926            other => panic!("expected the tool to be skipped, got {other:?}"),
927        }
928        // Skipping means not spawning it, so this must not cost another timeout.
929        assert!(
930            started.elapsed() < Duration::from_millis(50),
931            "skipping still took {:?}",
932            started.elapsed()
933        );
934    }
935
936    /// One slow block must not disable a tool for the rest of the run, so exiting on its
937    /// own clears whatever a tool accumulated before.
938    #[test]
939    #[cfg(unix)]
940    fn test_exiting_normally_clears_earlier_timeouts() {
941        let executor = ToolExecutor::isolated(50);
942        // Both definitions run through `sh`, which is what the tally is keyed on.
943        let hangs = ToolDefinition {
944            command: vec!["sh".to_string(), "-c".to_string(), "exec sleep 30".to_string()],
945            stdin: true,
946            stdout: true,
947            lint_args: vec![],
948            format_args: vec![],
949        };
950        let exits = ToolDefinition {
951            command: vec!["sh".to_string(), "-c".to_string(), "cat".to_string()],
952            stdin: true,
953            stdout: true,
954            lint_args: vec![],
955            format_args: vec![],
956        };
957
958        for _ in 0..TIMEOUT_LIMIT - 1 {
959            assert!(matches!(
960                executor.execute(&hangs, "input", true, Some(50)),
961                Err(ExecutorError::Timeout { .. })
962            ));
963        }
964        assert_eq!(executor.timeout_count("sh"), TIMEOUT_LIMIT - 1);
965
966        // The 50ms default exists only to make `hangs` time out quickly. A tool that
967        // exits on its own needs room to spawn on a loaded machine, or it times out too.
968        let output = executor
969            .execute(&exits, "hello", true, Some(10_000))
970            .expect("cat should exit");
971        assert_eq!(output.stdout.trim(), "hello");
972        assert_eq!(executor.timeout_count("sh"), 0, "a clean exit must clear the tally");
973    }
974
975    /// The tally outlives one executor, since a fresh executor is built per file and a
976    /// hanging tool would otherwise be retried from scratch for every file in the run.
977    #[test]
978    fn test_the_shared_tally_carries_across_executors() {
979        // A name no real tool answers to, so this neither reads nor disturbs the tally
980        // of any tool another test in this process may be running.
981        let key = "rumdl-test-only-shared-tally-probe";
982        let first = ToolExecutor::new(50);
983        let second = ToolExecutor::new(50);
984        let alone = ToolExecutor::isolated(50);
985
986        let before = second.timeout_count(key);
987        first.record_timeout(key);
988
989        assert_eq!(
990            second.timeout_count(key),
991            before + 1,
992            "executors built for different files must share one tally"
993        );
994        assert_eq!(alone.timeout_count(key), 0, "an isolated executor keeps its own tally");
995
996        first.clear_timeouts(key);
997        assert_eq!(second.timeout_count(key), 0);
998    }
999}
1000
1001#[cfg(test)]
1002mod native_check_version_tests {
1003    use super::supports_native_check_version;
1004
1005    #[test]
1006    fn only_recognized_stable_versions_with_verified_stdin_support_are_admitted() {
1007        for (tool, version, expected) in [
1008            ("djlint", "djlint, version 1.39.4", false),
1009            ("djlint", "djlint, version 1.39.5", true),
1010            ("djlint", "djlint, version 1.100.0", true),
1011            ("shuck", "shuck 0.2.1", false),
1012            ("shuck", "shuck 0.2.2", true),
1013            ("shuck", "shuck 0.10.0", true),
1014            ("djlint", "djlint, version 1.46.4rc1", false),
1015            ("djlint", "djlint, version 1.39.5.post1", false),
1016            ("shuck", "shuck 0.2.3-beta.1", false),
1017            ("shuck", "shuck v0.2.2", false),
1018            ("shuck", "shuck 0.2.2+build", false),
1019            ("shuck", "shuck 0.2", false),
1020            ("shuck", "shuck 0.2.2.1", false),
1021            ("djlint", "shuck 1.46.4", false),
1022            ("djlint", "", false),
1023        ] {
1024            assert_eq!(supports_native_check_version(tool, version), expected, "{version}");
1025        }
1026    }
1027}