Skip to main content

asimov_runner/
output.rs

1// This is free and unencumbered software released into the public domain.
2
3//! Destinations and capture policies for a child process's standard output.
4//!
5//! The content-specific aliases all refer to [`Output`]; they describe expected
6//! payloads without parsing or validating them. [`NoOutput`] represents a program
7//! pattern with no output value, rather than a request to discard a stream.
8//!
9//! # Inherited graph output and error handling
10//!
11//! `GraphOutput::Inherited` normally connects the child's stdout directly to the
12//! parent's stdout, including any redirection or pipe attached to it. The runner
13//! does not capture, decode, or batch those bytes. Output `BatchOptions` therefore
14//! have no effect on inherited stdout, and do not control the child's own buffering.
15//! Program format options (such as `FetcherOptions::output`) still select the
16//! serialization. For graph consumers, batching of their input remains applicable.
17//!
18//! Graph-producing wrappers still return a stream. With inherited stdout, it
19//! yields no successful payload batches: it ends on successful completion or
20//! yields an error. **Consume that stream to completion**, even though stdout is
21//! already visible. Polling drives stdin feeding, captured stderr draining, and
22//! process waiting. The outer `execute().await?` handles startup errors; the
23//! inner `result?` handles later input, I/O, and exit failures. Dropping the stream
24//! immediately after spawning instead requests child termination. Output already
25//! written to stdout cannot be withdrawn if the child subsequently fails.
26//! Since the runner does not perform directly inherited stdout writes, failures
27//! of those writes must be reported by the child, typically through its exit status.
28//!
29//! ```no_run
30//! # #[cfg(feature = "std")]
31//! #[tokio::main(flavor = "current_thread")]
32//! async fn main() -> Result<(), asimov_runner::ExecutorError> {
33//!     use asimov_runner::{Fetcher, FetcherOptions, GraphOutput, StreamExt};
34//!
35//!     let mut fetcher = Fetcher::new(
36//!         "asimov-example-fetcher",
37//!         "https://example.com/resource",
38//!         GraphOutput::Inherited,
39//!         FetcherOptions::builder().output("jsonl").build(),
40//!     );
41//!
42//!     let mut execution = fetcher.execute().await?;
43//!     while let Some(result) = execution.next().await {
44//!         result?; // Propagate failures; there are no captured batches to print.
45//!     }
46//!     Ok(()) // Completion, not merely successful spawning, has been checked.
47//! }
48//! # #[cfg(not(feature = "std"))]
49//! # fn main() {}
50//! ```
51//!
52//! The same consumption loop applies to a graph-producing pipeline with an
53//! inherited-output tail; its stream reports `PipelineError` and checks all stages.
54//! A limited `Lister` is the routing exception: it pipes, counts, and forwards
55//! stdout to the parent so the local line cap cannot be bypassed. It still emits
56//! no payload batches, and polling is required to perform that forwarding. Reaching
57//! the cap deliberately stops the child without checking its eventual exit status;
58//! a zero limit starts no child. See the [lister execution contract][lister].
59//!
60//! [lister]: https://docs.rs/asimov-runner/latest/asimov_runner/struct.Lister.html#method.execute
61
62use alloc::boxed::Box;
63use derive_more::Debug;
64use tokio::io::AsyncWrite;
65
66/// An output stream with no prescribed content type.
67pub type AnyOutput = Output;
68/// Stdout handling for a graph producer, whose captured output is a JSONL batch stream.
69pub type GraphOutput = Output;
70/// The absence of an output value for a program pattern.
71pub type NoOutput = ();
72/// An output stream intended to contain a SPARQL query, such as a compiler's result.
73pub type QueryOutput = Output;
74/// An output stream intended to contain text, without enforcing an encoding.
75pub type TextOutput = Output;
76
77/// How a child's stdout should be connected or collected.
78///
79/// Only [`Captured`](Self::Captured) returns payload bytes. Ignored, inherited,
80/// and forwarded output produces an empty result payload. Execution still checks
81/// input delivery and process success for every mode.
82///
83/// With `std` enabled, conversion to `Stdio` only configures the stream; it does
84/// not copy bytes into a writer. The consuming conversion drops any stored
85/// writer, while `Output::as_stdio` preserves it.
86#[derive(Debug)]
87pub enum Output {
88    /// Discards output by connecting the stream to the null device.
89    Ignored,
90    /// Connects stdout to the parent's stdout, with no captured output batches.
91    /// Output batching settings do not apply. Consume graph execution streams
92    /// to completion to observe failures; see the [module example](self).
93    /// A limited lister routes through a line-counting pipe before forwarding.
94    Inherited,
95    /// Pipes output for streaming or collection into the program's result.
96    Captured,
97    /// Stores an asynchronous destination and requests a pipe for the child.
98    ///
99    /// Wrappers forward stdout incrementally with backpressure and flush the
100    /// writer at EOF, without shutting it down. No bytes are also captured.
101    /// Write/flush failures fail execution. Graph streams own the writer after
102    /// successful spawning; subsequent calls on that wrapper discard stdout.
103    /// Buffered wrappers retain the writer for reuse. Omitted from debug output.
104    AsyncWrite(#[debug(skip)] Box<dyn AsyncWrite + Send + Sync + Unpin>),
105}
106
107impl Output {
108    #[cfg(feature = "std")]
109    pub(crate) fn take_for_stream(&mut self) -> Self {
110        match self {
111            Self::Ignored => Self::Ignored,
112            Self::Inherited => Self::Inherited,
113            Self::Captured => Self::Captured,
114            Self::AsyncWrite(_) => core::mem::replace(self, Self::Ignored),
115        }
116    }
117
118    #[cfg(feature = "std")]
119    pub(crate) async fn read_from(
120        &mut self,
121        stdout: Option<tokio::process::ChildStdout>,
122    ) -> std::io::Result<alloc::vec::Vec<u8>> {
123        use alloc::vec::Vec;
124        use tokio::io::{AsyncReadExt, AsyncWriteExt};
125
126        let mut captured = Vec::new();
127        if let Some(mut stdout) = stdout {
128            match self {
129                Self::Captured => {
130                    stdout.read_to_end(&mut captured).await?;
131                },
132                Self::AsyncWrite(writer) => {
133                    tokio::io::copy(&mut stdout, writer).await?;
134                    writer.flush().await?;
135                },
136                Self::Ignored | Self::Inherited => {
137                    tokio::io::copy(&mut stdout, &mut tokio::io::sink()).await?;
138                },
139            }
140        }
141        Ok(captured)
142    }
143
144    /// Selects the child's stream configuration without consuming this value.
145    ///
146    /// Both [`Captured`](Self::Captured) and [`AsyncWrite`](Self::AsyncWrite)
147    /// request a pipe. Reading that pipe and forwarding bytes, if desired, are
148    /// separate operations that this method does not perform.
149    #[cfg(feature = "std")]
150    pub fn as_stdio(&self) -> std::process::Stdio {
151        use std::process::Stdio;
152        match self {
153            Output::Ignored => Stdio::null(),
154            Output::Inherited => Stdio::inherit(),
155            Output::Captured => Stdio::piped(),
156            Output::AsyncWrite(_) => Stdio::piped(),
157        }
158    }
159}
160
161#[cfg(feature = "std")]
162impl Into<std::process::Stdio> for Output {
163    fn into(self) -> std::process::Stdio {
164        use std::process::Stdio;
165        match self {
166            Output::Ignored => Stdio::null(),
167            Output::Inherited => Stdio::inherit(),
168            Output::Captured => Stdio::piped(),
169            Output::AsyncWrite(_) => Stdio::piped(),
170        }
171    }
172}