Skip to main content

asimov_runner/
executor.rs

1// This is free and unencumbered software released into the public domain.
2
3//! Low-level Tokio child-process execution with ASIMOV executable lookup.
4//!
5//! [`Executor`] configures a command without starting it. Call
6//! [`Executor::execute`] for a complete spawn-and-wait cycle, or use
7//! [`Executor::spawn`] and [`Executor::wait`] to interact with the child between
8//! those steps. `execute` and `wait` buffer captured output until the child exits;
9//! `spawn` returns the live child handle without collecting its output.
10//! [`Executor::execute_jsonl`] and [`Executor::execute_jsonl_with_input`] instead
11//! return live batch streams with concurrent input/output handling.
12
13use crate::{
14    Command, ExecutionCompletion, ExecutorError, ExecutorResult, Input, InputCompletion, Output,
15};
16use alloc::borrow::ToOwned;
17use std::{ffi::OsStr, io::ErrorKind, process::Stdio};
18use tokio::process::Child;
19
20/// A configured command that reports process failures as [`ExecutorError`].
21///
22/// Executables are looked up in the ASIMOV root's `libexec` directory before
23/// falling back to the supplied program name or path. By default, all three
24/// standard streams are connected to the null device, `NO_COLOR=1` is set, and
25/// spawned children are configured to be killed when their handles are dropped.
26/// Use [`command`](Self::command) to customize these settings before execution.
27///
28/// Each execution spawns a new process using the stored command configuration.
29/// Stdout is returned only when configured as a pipe; otherwise the successful
30/// result is an empty cursor or stream. Captured stderr is included in process
31/// exit errors when it is valid UTF-8. [`ExecutionCompletion`] exposes exit status,
32/// input delivery, and diagnostics separately; its `into_result` method defines
33/// the error precedence used by the convenience APIs.
34///
35/// # Example
36///
37/// Prepare a piped input and capture both output streams before spawning:
38///
39/// ```no_run
40/// use asimov_runner::{Executor, Input};
41/// use std::io::Cursor;
42///
43/// # async fn example() -> Result<(), asimov_runner::ExecutorError> {
44/// let mut input = Input::AsyncRead(Box::new(Cursor::new(b"example input".to_vec())));
45/// let mut executor = Executor::new("asimov-example-reader");
46/// executor.command().stdin(input.as_stdio());
47/// executor.capture_stdout();
48/// executor.capture_stderr();
49///
50/// let bytes = executor.execute_with_input(&mut input).await?.into_inner();
51/// # Ok(())
52/// # }
53/// ```
54#[derive(Debug)]
55pub struct Executor(Command, crate::BatchOptions);
56
57impl Executor {
58    /// Prepares a command with the executor's default environment and streams.
59    ///
60    /// If `<asimov_root>/libexec/<program>` exists, it is selected; otherwise
61    /// `program` is passed to Tokio unchanged, allowing normal `PATH` lookup for
62    /// a bare name. Existence does not guarantee that the selected file can be
63    /// executed. Such errors are reported when the command is spawned.
64    pub fn new(program: impl AsRef<OsStr>) -> Self {
65        let libexec_path = asimov_env::paths::asimov_root()
66            .join("libexec")
67            .join(program.as_ref());
68
69        let mut command = if libexec_path.exists() {
70            Command::new(libexec_path)
71        } else {
72            Command::new(program)
73        };
74
75        command.env("NO_COLOR", "1"); // See: https://no-color.org
76        command.stdin(Stdio::null());
77        command.stdout(Stdio::null());
78        command.stderr(Stdio::null());
79        command.kill_on_drop(true);
80        Self(command, crate::BatchOptions::default())
81    }
82
83    /// Configures batching for captured JSONL results, without changing the
84    /// subprocess command or byte-oriented execution methods.
85    #[must_use]
86    pub fn with_batching(mut self, options: crate::BatchOptions) -> Self {
87        self.1 = options;
88        self
89    }
90
91    /// The policy used for captured JSONL batches.
92    pub fn batch_options(&self) -> crate::BatchOptions {
93        self.1
94    }
95
96    /// Returns the underlying command for configuring arguments, environment,
97    /// working directory, standard streams, or process-lifetime behavior.
98    pub fn command(&mut self) -> &mut Command {
99        &mut self.0
100    }
101
102    /// Connects the child's stdin to the null device, giving it immediate EOF.
103    pub fn ignore_stdin(&mut self) {
104        self.0.stdin(Stdio::null());
105    }
106
107    /// Discards the child's stdout by connecting it to the null device.
108    pub fn ignore_stdout(&mut self) {
109        self.0.stdout(Stdio::null());
110    }
111
112    /// Discards the child's stderr, leaving no diagnostic text to capture.
113    pub fn ignore_stderr(&mut self) {
114        self.0.stderr(Stdio::null());
115    }
116
117    /// Pipes stdout for buffered execution or live JSONL batch streaming.
118    pub fn capture_stdout(&mut self) {
119        self.0.stdout(Stdio::piped());
120    }
121
122    /// Pipes stderr so execution can attach its UTF-8 contents to exit errors.
123    pub fn capture_stderr(&mut self) {
124        self.0.stderr(Stdio::piped());
125    }
126
127    /// Spawns the command and waits for completion, returning captured stdout.
128    ///
129    /// No input is written by this method. Use
130    /// [`execute_with_input`](Self::execute_with_input) to copy an input stream
131    /// into the child.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if spawning or waiting fails, or if the child exits
136    /// unsuccessfully. See [`ExecutorError`] for the error categories.
137    pub async fn execute(&mut self) -> ExecutorResult {
138        let process = self.spawn().await?;
139        self.wait(process).await
140    }
141
142    /// Spawns the command, copies `input` into stdin, and waits for completion.
143    ///
144    /// Configure stdin with `input.as_stdio()` through [`command`](Self::command)
145    /// before calling this method. [`Input::Ignored`] performs no copy and does
146    /// not change the command's stdin configuration. An asynchronous reader is
147    /// normally consumed from its current position to EOF, then the stdin pipe
148    /// is closed. Reusing the input does not rewind it or restore partly written
149    /// bytes after early exit or cancellation.
150    ///
151    /// Input is fed concurrently with draining stdout and stderr. JSONL input
152    /// is coalesced per batch with backpressure and source error propagation.
153    /// Early child exit cancels a pending input feed and is reported as
154    /// [`ExecutorError::IncompleteInput`] on an otherwise successful exit. Use
155    /// [`execute_with_io_completion`](Self::execute_with_io_completion) to inspect
156    /// intentional early exit. Success confirms EOF and delivery to the pipe,
157    /// not application-level processing of the supplied data.
158    ///
159    /// # Errors
160    ///
161    /// Returns an error if spawning, copying input, or waiting fails, or if the
162    /// child exits unsuccessfully. Non-ignored input requires piped stdin;
163    /// otherwise feeding it returns an I/O `InvalidInput` error.
164    pub async fn execute_with_input(&mut self, input: &mut Input) -> ExecutorResult {
165        self.execute_with_io(input, &mut Output::Captured).await
166    }
167
168    /// Feeds input and routes stdout concurrently, requiring complete delivery
169    /// and a successful exit. Configure stdin/stdout from `input.as_stdio()` and
170    /// `output.as_stdio()` first. Only [`Output::Captured`] returns payload bytes.
171    ///
172    /// # Errors
173    ///
174    /// Returns spawn, transport, forwarding, input-delivery, or exit errors using
175    /// the precedence documented by [`ExecutionCompletion::into_result`].
176    pub async fn execute_with_io(
177        &mut self,
178        input: &mut Input,
179        output: &mut Output,
180    ) -> ExecutorResult {
181        self.execute_with_io_completion(input, output)
182            .await?
183            .into_result()
184    }
185
186    /// Executes with explicit exit and input-delivery outcomes.
187    ///
188    /// Configure stdin/stdout from the supplied input/output policies before
189    /// calling. Input and writers are borrowed and retained for reuse, without
190    /// rewinding. Stdout forwarding and stderr collection run concurrently with
191    /// feeding stdin and waiting for exit. Early exit cancels pending input;
192    /// source errors terminate the child, while broken pipes allow it to finish
193    /// naturally so its exit diagnostics are preserved. Ready input outcomes are
194    /// polled before exit to give EOF and source errors consistent precedence.
195    ///
196    /// # Errors
197    ///
198    /// Only spawn, stdout/stderr transport, writer, and wait failures are returned
199    /// directly. Child exit and input failures are fields of the returned
200    /// [`ExecutionCompletion`], even when the exit status is unsuccessful.
201    pub async fn execute_with_io_completion(
202        &mut self,
203        input: &mut Input,
204        output: &mut Output,
205    ) -> Result<ExecutionCompletion, ExecutorError> {
206        communicate(self.spawn().await?, input, output).await
207    }
208
209    /// Starts a new child process using the current command configuration.
210    ///
211    /// The returned handle provides access to any piped standard streams. Unless
212    /// overridden through [`command`](Self::command), dropping that handle before
213    /// completion requests termination of that child, without waiting for it to
214    /// be reaped or guaranteeing termination of its descendants.
215    ///
216    /// # Errors
217    ///
218    /// Maps an I/O `NotFound` error to [`ExecutorError::MissingProgram`] and all
219    /// other spawn errors to [`ExecutorError::SpawnFailure`].
220    pub async fn spawn(&mut self) -> Result<Child, ExecutorError> {
221        match self.0.spawn() {
222            Ok(process) => Ok(process),
223            Err(err) if err.kind() == ErrorKind::NotFound => {
224                let program = self.0.as_std().get_program().to_owned();
225                return Err(ExecutorError::MissingProgram(program));
226            },
227            Err(err) => return Err(ExecutorError::SpawnFailure(err)),
228        }
229    }
230
231    /// Collects the child's remaining piped output and waits for it to exit.
232    ///
233    /// Any stdin handle still owned by `process` is closed before waiting.
234    /// On success, returns all captured stdout in a cursor positioned at zero.
235    /// Streams whose handles were taken from the child are not collected here.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`ExecutorError::UnexpectedOther`] for I/O errors. An unsuccessful
240    /// exit becomes [`ExecutorError::Failure`] for a recognized sysexits status
241    /// or [`ExecutorError::UnexpectedFailure`] otherwise, with captured UTF-8
242    /// stderr attached. Stdout is not retained in either failure variant.
243    pub async fn wait(&mut self, process: Child) -> ExecutorResult {
244        communicate(process, &mut Input::Ignored, &mut Output::Captured)
245            .await?
246            .into_result()
247    }
248}
249
250/// Supervises input and process exit independently from draining stdout/stderr.
251pub(crate) async fn communicate(
252    mut process: Child,
253    input: &mut Input,
254    output: &mut Output,
255) -> Result<ExecutionCompletion, ExecutorError> {
256    communicate_child(&mut process, input, output).await
257}
258
259/// Borrows the child so a pipeline supervisor can cancel I/O, then kill and reap
260/// the same child. The owning caller retains kill-on-drop cancellation behavior.
261pub(crate) async fn communicate_child(
262    process: &mut Child,
263    input: &mut Input,
264    output: &mut Output,
265) -> Result<ExecutionCompletion, ExecutorError> {
266    use crate::completion::InputFailure;
267    use alloc::vec::Vec;
268    use tokio::io::AsyncReadExt;
269
270    let stdin = process.stdin.take();
271    let stdout = process.stdout.take();
272    let stderr = process.stderr.take();
273    let supervise = async {
274        let feed = input.write_to(stdin);
275        tokio::pin!(feed);
276        let input = tokio::select! {
277            biased;
278            result = &mut feed => match result {
279                Ok(()) => InputCompletion::Complete,
280                Err(InputFailure::Source(error)) => {
281                    process.start_kill()?;
282                    InputCompletion::SourceFailed(error)
283                },
284                Err(InputFailure::Write(error)) => {
285                    if error.kind() != ErrorKind::BrokenPipe {
286                        process.start_kill()?;
287                    }
288                    InputCompletion::WriteFailed(error)
289                },
290            },
291            status = process.wait() => {
292                return Ok::<_, std::io::Error>((status?, InputCompletion::Interrupted));
293            },
294        };
295        Ok((process.wait().await?, input))
296    };
297    let read_stderr = async {
298        let mut bytes = Vec::new();
299        if let Some(mut stderr) = stderr {
300            stderr.read_to_end(&mut bytes).await?;
301        }
302        Ok::<_, std::io::Error>(bytes)
303    };
304    let result = tokio::try_join!(supervise, output.read_from(stdout), read_stderr);
305    match result {
306        Ok(((status, input), stdout, stderr)) => {
307            #[cfg(feature = "tracing")]
308            tracing::trace!("The command exited with: {}", status);
309            Ok(ExecutionCompletion {
310                output: std::process::Output {
311                    status,
312                    stdout,
313                    stderr,
314                },
315                input,
316            })
317        },
318        Err(error) => {
319            // A failed destination must not leave the producer blocked on its pipes.
320            let _ = process.start_kill();
321            let _ = process.wait().await;
322            Err(error.into())
323        },
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[tokio::test]
332    async fn test_success() {
333        let mut runner = Executor::new("curl");
334        runner.command().arg("https://www.google.com");
335        let result = runner.execute().await;
336        assert!(result.is_ok());
337    }
338
339    #[tokio::test]
340    async fn test_missing_program() {
341        let mut runner = Executor::new("this-command-does-not-exist");
342        let result = runner.execute().await;
343        assert!(matches!(result, Err(ExecutorError::MissingProgram(_))));
344    }
345
346    #[cfg(unix)]
347    #[tokio::test]
348    async fn test_spawn_failure() {
349        let mut runner = Executor::new("/dev/null");
350        let result = runner.execute().await;
351        assert!(matches!(result, Err(ExecutorError::SpawnFailure(_))));
352    }
353
354    #[tokio::test]
355    async fn test_unexpected_failure() {
356        let mut runner = Executor::new("curl");
357        let result = runner.execute().await;
358        assert!(matches!(
359            result,
360            Err(ExecutorError::UnexpectedFailure(_, _))
361        ));
362    }
363}