1use crate::context::{Host, LogLevel};
7
8#[derive(Clone, Copy, PartialEq, Eq, Debug)]
10pub enum StdioStream {
11 Stdout,
12 Stderr,
13}
14
15#[derive(Clone, Debug)]
17pub struct OutputChunk {
18 pub stream: StdioStream,
19 pub text: String,
20}
21
22#[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
32pub struct ProcessOutput {
34 pub exit_code: i32,
35 pub chunks: Vec<OutputChunk>,
36}
37
38pub trait ChildHandle {
41 fn next_line(&mut self) -> Option<OutputChunk>;
42 fn wait(&mut self) -> i32;
43 fn kill(&mut self);
44}
45
46pub 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 #[must_use]
72 pub fn success(&self) -> bool {
73 self.exit_code == 0
74 }
75 #[must_use]
77 pub fn stdout(&self) -> String {
78 self.stdout.join("\n")
79 }
80 #[must_use]
82 pub fn stderr(&self) -> String {
83 self.stderr.join("\n")
84 }
85}
86
87#[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 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 pub fn at(level: LogLevel) -> Self {
114 Self {
115 classify: Box::new(move |_| Some(level)),
116 }
117 }
118 pub fn silent() -> Self {
120 Self {
121 classify: Box::new(|_| None),
122 }
123 }
124 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#[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 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 #[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 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#[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 #[must_use]
230 pub fn next_line(&mut self) -> Option<OutputChunk> {
231 self.handle.next_line()
232 }
233 pub fn wait(&mut self) -> i32 {
235 self.handle.wait()
236 }
237 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}