Skip to main content

moonlit_pdk/
process.rs

1//! Safe, native-testable wrappers over the host `process` capability. `Command`
2//! builds a subprocess; `.run()` captures, `.stream()` live-logs via a
3//! `LineHandler`, `.spawn()` yields a `Child` for line-by-line control. Non-zero
4//! exit is data (`Ok`), not an error; `Err` means the spawn itself failed.
5
6use crate::context::{Host, LogLevel};
7
8/// Which standard stream a line came from.
9#[derive(Clone, Copy, PartialEq, Eq, Debug)]
10pub enum StdioStream {
11  Stdout,
12  Stderr,
13}
14
15/// One line of subprocess output.
16#[derive(Clone, Debug)]
17pub struct OutputChunk {
18  pub stream: StdioStream,
19  pub text: String,
20}
21
22/// A subprocess specification (plain-Rust DTO crossing the `Host` boundary).
23#[derive(Clone, Debug, Default)]
24pub struct ProcessCommand {
25  pub program: String,
26  pub args: Vec<String>,
27  pub cwd: Option<String>,
28  pub env: Vec<(String, String)>,
29  pub stdin: Option<String>,
30}
31
32/// Raw result of a run-to-completion (`Host::process_run`).
33pub struct ProcessOutput {
34  pub exit_code: i32,
35  pub chunks: Vec<OutputChunk>,
36}
37
38/// A live child process (`Host::process_spawn` return). Object-safe so the real
39/// host (wasm `child` resource) and the mock host (canned script) both fit.
40pub trait ChildHandle {
41  fn next_line(&mut self) -> Option<OutputChunk>;
42  fn wait(&mut self) -> i32;
43  fn kill(&mut self);
44}
45
46/// Captured output of a finished subprocess.
47pub struct Output {
48  pub exit_code: i32,
49  pub stdout: Vec<String>,
50  pub stderr: Vec<String>,
51}
52
53impl Output {
54  #[must_use]
55  fn from_raw(raw: ProcessOutput) -> Self {
56    let mut stdout = Vec::new();
57    let mut stderr = Vec::new();
58    for c in raw.chunks {
59      match c.stream {
60        StdioStream::Stdout => stdout.push(c.text),
61        StdioStream::Stderr => stderr.push(c.text),
62      }
63    }
64    Self {
65      exit_code: raw.exit_code,
66      stdout,
67      stderr,
68    }
69  }
70  /// True when the process exited 0.
71  #[must_use]
72  pub fn success(&self) -> bool {
73    self.exit_code == 0
74  }
75  /// stdout lines joined with '\n'.
76  #[must_use]
77  pub fn stdout(&self) -> String {
78    self.stdout.join("\n")
79  }
80  /// stderr lines joined with '\n'.
81  #[must_use]
82  pub fn stderr(&self) -> String {
83    self.stderr.join("\n")
84  }
85}
86
87/// Maps an output line to a log level (or `None` to suppress).
88#[must_use = "a line handler does nothing until it is passed to `.stream()`"]
89#[allow(clippy::type_complexity)]
90pub struct LineHandler {
91  classify: Box<dyn Fn(&OutputChunk) -> Option<LogLevel>>,
92}
93
94impl LineHandler {
95  /// Standard severity heuristic: "error"/"failed" -> Error, "warning" -> Warn,
96  /// else Info (case-insensitive on the line text).
97  pub fn severity() -> Self {
98    Self {
99      classify: Box::new(|chunk| {
100        let lower = chunk.text.to_ascii_lowercase();
101        let level = if lower.contains("error") || lower.contains("failed") {
102          LogLevel::Error
103        } else if lower.contains("warning") {
104          LogLevel::Warn
105        } else {
106          LogLevel::Info
107        };
108        Some(level)
109      }),
110    }
111  }
112  /// Log every line at a fixed level.
113  pub fn at(level: LogLevel) -> Self {
114    Self {
115      classify: Box::new(move |_| Some(level)),
116    }
117  }
118  /// Capture only; never log.
119  pub fn silent() -> Self {
120    Self {
121      classify: Box::new(|_| None),
122    }
123  }
124  /// Custom classification.
125  pub fn custom(f: impl Fn(&OutputChunk) -> Option<LogLevel> + 'static) -> Self {
126    Self { classify: Box::new(f) }
127  }
128  #[must_use]
129  fn level_for(&self, chunk: &OutputChunk) -> Option<LogLevel> {
130    (self.classify)(chunk)
131  }
132}
133
134/// Fluent subprocess builder, created via `ctx.command(program)`.
135#[must_use = "a command does nothing until `.run()`, `.spawn()` or `.stream()` is called"]
136pub struct Command<'a> {
137  host: &'a dyn Host,
138  cmd: ProcessCommand,
139}
140
141impl<'a> Command<'a> {
142  pub(crate) fn new(host: &'a dyn Host, program: impl Into<String>) -> Self {
143    Self {
144      host,
145      cmd: ProcessCommand {
146        program: program.into(),
147        ..Default::default()
148      },
149    }
150  }
151  pub fn arg(mut self, a: impl Into<String>) -> Self {
152    self.cmd.args.push(a.into());
153    self
154  }
155  pub fn args<I, S>(mut self, args: I) -> Self
156  where
157    I: IntoIterator<Item = S>,
158    S: Into<String>,
159  {
160    self.cmd.args.extend(args.into_iter().map(Into::into));
161    self
162  }
163  pub fn cwd(mut self, dir: impl Into<String>) -> Self {
164    self.cmd.cwd = Some(dir.into());
165    self
166  }
167  pub fn env(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
168    self.cmd.env.push((k.into(), v.into()));
169    self
170  }
171  pub fn envs<I, K, V>(mut self, vars: I) -> Self
172  where
173    I: IntoIterator<Item = (K, V)>,
174    K: Into<String>,
175    V: Into<String>,
176  {
177    self.cmd.env.extend(vars.into_iter().map(|(k, v)| (k.into(), v.into())));
178    self
179  }
180  pub fn stdin(mut self, input: impl Into<String>) -> Self {
181    self.cmd.stdin = Some(input.into());
182    self
183  }
184
185  /// Run to completion, capturing output silently.
186  pub fn run(&self) -> Result<Output, String> {
187    let raw = self.host.process_run(&self.cmd)?;
188    Ok(Output::from_raw(raw))
189  }
190
191  /// Spawn and stream: route each line through `handler` to the host log, and
192  /// also capture everything into the returned `Output`.
193  #[expect(
194    clippy::needless_pass_by_value,
195    reason = "public SDK API: plugin authors pass a handler inline"
196  )]
197  pub fn stream(&self, handler: LineHandler) -> Result<Output, String> {
198    let mut child = self.host.process_spawn(&self.cmd)?;
199    let mut chunks = Vec::new();
200    while let Some(chunk) = child.next_line() {
201      if let Some(level) = handler.level_for(&chunk) {
202        self.host.log(level, &chunk.text);
203      }
204      chunks.push(chunk);
205    }
206    let exit_code = child.wait();
207    Ok(Output::from_raw(ProcessOutput { exit_code, chunks }))
208  }
209
210  /// Spawn for manual line-by-line control.
211  pub fn spawn(&self) -> Result<Child<'a>, String> {
212    let handle = self.host.process_spawn(&self.cmd)?;
213    Ok(Child {
214      handle,
215      _marker: std::marker::PhantomData,
216    })
217  }
218}
219
220/// A live child process handle.
221#[must_use = "a spawned process should be read with `.next_line()` or awaited with `.wait()`"]
222pub struct Child<'a> {
223  handle: Box<dyn ChildHandle>,
224  _marker: std::marker::PhantomData<&'a ()>,
225}
226
227impl Child<'_> {
228  /// Next output line, or `None` when the process has exited.
229  #[must_use]
230  pub fn next_line(&mut self) -> Option<OutputChunk> {
231    self.handle.next_line()
232  }
233  /// Wait for exit and return the code.
234  pub fn wait(&mut self) -> i32 {
235    self.handle.wait()
236  }
237  /// Kill the process.
238  pub fn kill(&mut self) {
239    self.handle.kill();
240  }
241}
242
243#[cfg(test)]
244mod tests {
245  use super::*;
246  use crate::Context;
247  use crate::context::LogLevel;
248  use crate::testing::MockHost;
249
250  fn chunk(stream: StdioStream, text: &str) -> OutputChunk {
251    OutputChunk {
252      stream,
253      text: text.to_string(),
254    }
255  }
256
257  #[test]
258  fn run_captures_output_and_records_command() {
259    let host = MockHost::new().with_process_result(
260      0,
261      vec![chunk(StdioStream::Stdout, "hello"), chunk(StdioStream::Stderr, "note")],
262    );
263    let ctx = Context::new(&host, "/w".into(), "s".into());
264    let out = ctx.command("echo").arg("hello").run().unwrap();
265    assert!(out.success());
266    assert_eq!(out.stdout(), "hello");
267    assert_eq!(out.stderr(), "note");
268    let cmds = host.recorded_commands();
269    assert_eq!(cmds[0].program, "echo");
270    assert_eq!(cmds[0].args, vec!["hello".to_string()]);
271  }
272
273  #[test]
274  fn stream_routes_lines_through_severity_handler() {
275    let host = MockHost::new().with_process_result(
276      0,
277      vec![
278        chunk(StdioStream::Stdout, "building"),
279        chunk(StdioStream::Stderr, "ERROR: boom"),
280        chunk(StdioStream::Stdout, "warning: hmm"),
281      ],
282    );
283    let ctx = Context::new(&host, "/w".into(), "s".into());
284    let out = ctx.command("docker").stream(LineHandler::severity()).unwrap();
285    assert_eq!(out.exit_code, 0);
286    assert_eq!(
287      host.logs(),
288      vec![
289        (LogLevel::Info, "building".to_string()),
290        (LogLevel::Error, "ERROR: boom".to_string()),
291        (LogLevel::Warn, "warning: hmm".to_string()),
292      ]
293    );
294  }
295
296  #[test]
297  fn spawn_yields_lines_then_exit_code() {
298    let host = MockHost::new().with_process_result(3, vec![chunk(StdioStream::Stdout, "a"), chunk(StdioStream::Stdout, "b")]);
299    let ctx = Context::new(&host, "/w".into(), "s".into());
300    let mut child = ctx.command("sh").spawn().unwrap();
301    let mut lines = Vec::new();
302    while let Some(c) = child.next_line() {
303      lines.push(c.text);
304    }
305    assert_eq!(lines, vec!["a".to_string(), "b".to_string()]);
306    assert_eq!(child.wait(), 3);
307  }
308
309  #[test]
310  fn spawn_failure_is_err_not_panic() {
311    let host = MockHost::new().with_process_error("program 'x' not permitted");
312    let ctx = Context::new(&host, "/w".into(), "s".into());
313    let e = ctx.command("x").run().err().expect("expected spawn failure");
314    assert!(e.contains("not permitted"), "got: {e}");
315  }
316
317  #[test]
318  fn spawn_and_stream_failures_are_err() {
319    let host = MockHost::new()
320      .with_process_error("spawn denied")
321      .with_process_error("stream denied");
322    let ctx = Context::new(&host, "/w".into(), "s".into());
323    assert_eq!(ctx.command("x").spawn().err().expect("spawn error"), "spawn denied");
324    assert_eq!(
325      ctx.command("x").stream(LineHandler::silent()).err().expect("stream error"),
326      "stream denied"
327    );
328  }
329
330  #[test]
331  fn builder_records_args_cwd_env_and_stdin() {
332    let host = MockHost::new().with_process_result(0, vec![]);
333    let ctx = Context::new(&host, "/w".into(), "s".into());
334    let out = ctx
335      .command("git")
336      .arg("commit")
337      .args(["-m", "msg"])
338      .cwd("/repo")
339      .env("A", "1")
340      .envs([("B", "2"), ("C", "3")])
341      .stdin("input")
342      .run()
343      .unwrap();
344    assert!(out.success());
345    let cmd = &host.recorded_commands()[0];
346    assert_eq!(cmd.args, vec!["commit", "-m", "msg"]);
347    assert_eq!(cmd.cwd.as_deref(), Some("/repo"));
348    assert_eq!(
349      cmd.env,
350      vec![
351        ("A".to_string(), "1".to_string()),
352        ("B".to_string(), "2".to_string()),
353        ("C".to_string(), "3".to_string()),
354      ]
355    );
356    assert_eq!(cmd.stdin.as_deref(), Some("input"));
357  }
358
359  #[test]
360  fn fixed_level_handler_logs_every_line_at_that_level() {
361    let host = MockHost::new().with_process_result(1, vec![chunk(StdioStream::Stdout, "a"), chunk(StdioStream::Stderr, "b")]);
362    let ctx = Context::new(&host, "/w".into(), "s".into());
363    let out = ctx.command("x").stream(LineHandler::at(LogLevel::Debug)).unwrap();
364    assert!(!out.success());
365    assert_eq!(
366      host.logs(),
367      vec![(LogLevel::Debug, "a".to_string()), (LogLevel::Debug, "b".to_string())]
368    );
369  }
370
371  #[test]
372  fn custom_handler_classifies_by_stream() {
373    let host =
374      MockHost::new().with_process_result(0, vec![chunk(StdioStream::Stdout, "out"), chunk(StdioStream::Stderr, "err")]);
375    let ctx = Context::new(&host, "/w".into(), "s".into());
376    let handler = LineHandler::custom(|c| (c.stream == StdioStream::Stderr).then_some(LogLevel::Warn));
377    let out = ctx.command("x").stream(handler).unwrap();
378    assert_eq!(out.stdout(), "out");
379    assert_eq!(out.stderr(), "err");
380    assert_eq!(host.logs(), vec![(LogLevel::Warn, "err".to_string())]);
381  }
382
383  #[test]
384  fn spawned_child_can_be_killed() {
385    let host = MockHost::new().with_process_result(0, vec![chunk(StdioStream::Stdout, "a")]);
386    let ctx = Context::new(&host, "/w".into(), "s".into());
387    let mut child = ctx.command("sleep").spawn().unwrap();
388    child.kill();
389    assert_eq!(child.wait(), 0);
390  }
391
392  #[test]
393  fn silent_handler_suppresses_logs() {
394    let host = MockHost::new().with_process_result(0, vec![chunk(StdioStream::Stdout, "quiet")]);
395    let ctx = Context::new(&host, "/w".into(), "s".into());
396    let out = ctx.command("echo").stream(LineHandler::silent()).unwrap();
397    assert_eq!(out.stdout(), "quiet");
398    assert!(host.logs().is_empty());
399  }
400}