Skip to main content

nomoreide_core/
exec_file.rs

1//! `execFile`, including how it reports a failure.
2//!
3//! Several surfaces run a program, hand the caller its output, and hand the
4//! caller Node's *own* failure wording when it exits non-zero — the message is
5//! rendered where the output would have gone, so it is part of the answer
6//! rather than a log line. Node words it
7//! `Command failed: <file> <args…>\n<stderr>`, which means the full argv is
8//! quoted back, embedded remote scripts and all.
9//!
10//! Bytes rather than a `String`, because a file preview may be binary and the
11//! caller decides how to decode it. The one place a lossy decode is forced is
12//! the error message, which is text by construction.
13
14use std::process::Stdio;
15use std::time::Duration;
16use tokio::process::Command;
17
18pub struct ExecOptions<'a> {
19    pub timeout: Duration,
20    /// Node kills the child and rejects once either stream passes this. Here
21    /// the child has already finished, so it is checked after the fact — the
22    /// difference is invisible in the answer and visible only in how long a
23    /// runaway program is allowed to run.
24    pub max_buffer: usize,
25    pub cwd: Option<&'a str>,
26}
27
28#[derive(Debug)]
29pub struct ExecOutput {
30    pub stdout: Vec<u8>,
31    pub stderr: Vec<u8>,
32}
33
34/// The program ran and said something. `failure` is set when it exited
35/// non-zero — the output is still there, because Node hangs `stdout` on the
36/// error object and one caller reads it.
37pub struct ExecAttempt {
38    pub output: ExecOutput,
39    pub failure: Option<String>,
40}
41
42/// Run a program, and hand back what it printed **even when it failed**.
43///
44/// `claude mcp list` is why this exists: it prints a usable table and then
45/// exits non-zero, and the reference parses the table anyway. An `Err` here is
46/// only for a run that produced nothing to read — a spawn failure, a timeout,
47/// or output past the buffer.
48pub async fn exec_file_capturing(
49    argv: &[String],
50    options: &ExecOptions<'_>,
51) -> Result<ExecAttempt, String> {
52    let (program, args) = argv.split_first().ok_or("no command")?;
53    let mut command = Command::new(program);
54    command
55        .args(args)
56        .stdin(Stdio::null())
57        .stdout(Stdio::piped())
58        .stderr(Stdio::piped());
59    if let Some(cwd) = options.cwd.filter(|value| !value.is_empty()) {
60        command.current_dir(cwd);
61    }
62    let child = command
63        .spawn()
64        .map_err(|error| format!("spawn {program} {}", errno_name(&error)))?;
65    let output = tokio::time::timeout(options.timeout, child.wait_with_output())
66        .await
67        .map_err(|_| format!("Command failed: {}", argv.join(" ")))?
68        .map_err(|error| error.to_string())?;
69
70    if output.stdout.len() > options.max_buffer || output.stderr.len() > options.max_buffer {
71        return Err("stdout maxBuffer length exceeded".to_string());
72    }
73    let failure = (!output.status.success()).then(|| {
74        format!(
75            "Command failed: {}\n{}",
76            argv.join(" "),
77            String::from_utf8_lossy(&output.stderr)
78        )
79    });
80    Ok(ExecAttempt {
81        output: ExecOutput {
82            stdout: output.stdout,
83            stderr: output.stderr,
84        },
85        failure,
86    })
87}
88
89/// The common case: a non-zero exit is a failure and the output is discarded.
90pub async fn exec_file(argv: &[String], options: &ExecOptions<'_>) -> Result<ExecOutput, String> {
91    let attempt = exec_file_capturing(argv, options).await?;
92    match attempt.failure {
93        Some(failure) => Err(failure),
94        None => Ok(attempt.output),
95    }
96}
97
98/// The `code` Node puts on a spawn failure, which callers quote rather than the
99/// operating system's prose.
100fn errno_name(error: &std::io::Error) -> &'static str {
101    match error.kind() {
102        std::io::ErrorKind::NotFound => "ENOENT",
103        std::io::ErrorKind::PermissionDenied => "EACCES",
104        _ => "EIO",
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn options() -> ExecOptions<'static> {
113        ExecOptions {
114            timeout: Duration::from_secs(10),
115            max_buffer: 1024 * 1024,
116            cwd: None,
117        }
118    }
119
120    #[tokio::test]
121    async fn a_failure_quotes_the_whole_command_and_then_stderr() {
122        let argv = vec![
123            "sh".to_string(),
124            "-c".to_string(),
125            "printf 'went wrong\n' >&2; exit 3".to_string(),
126        ];
127        let failure = exec_file(&argv, &options()).await.unwrap_err();
128        assert_eq!(
129            failure,
130            "Command failed: sh -c printf 'went wrong\n' >&2; exit 3\nwent wrong\n"
131        );
132    }
133
134    #[tokio::test]
135    async fn output_comes_back_as_bytes() {
136        let argv = vec![
137            "sh".to_string(),
138            "-c".to_string(),
139            r"printf 'a\0b'".to_string(),
140        ];
141        let output = exec_file(&argv, &options()).await.unwrap();
142        assert_eq!(output.stdout, vec![b'a', 0, b'b']);
143    }
144}