use crate::context::{Host, LogLevel};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum StdioStream {
Stdout,
Stderr,
}
#[derive(Clone, Debug)]
pub struct OutputChunk {
pub stream: StdioStream,
pub text: String,
}
#[derive(Clone, Debug, Default)]
pub struct ProcessCommand {
pub program: String,
pub args: Vec<String>,
pub cwd: Option<String>,
pub env: Vec<(String, String)>,
pub stdin: Option<String>,
}
pub struct ProcessOutput {
pub exit_code: i32,
pub chunks: Vec<OutputChunk>,
}
pub trait ChildHandle {
fn next_line(&mut self) -> Option<OutputChunk>;
fn wait(&mut self) -> i32;
fn kill(&mut self);
}
pub struct Output {
pub exit_code: i32,
pub stdout: Vec<String>,
pub stderr: Vec<String>,
}
impl Output {
fn from_raw(raw: ProcessOutput) -> Self {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
for c in raw.chunks {
match c.stream {
StdioStream::Stdout => stdout.push(c.text),
StdioStream::Stderr => stderr.push(c.text),
}
}
Self {
exit_code: raw.exit_code,
stdout,
stderr,
}
}
pub fn success(&self) -> bool {
self.exit_code == 0
}
pub fn stdout(&self) -> String {
self.stdout.join("\n")
}
pub fn stderr(&self) -> String {
self.stderr.join("\n")
}
}
#[allow(clippy::type_complexity)]
pub struct LineHandler {
classify: Box<dyn Fn(&OutputChunk) -> Option<LogLevel>>,
}
impl LineHandler {
pub fn severity() -> Self {
Self {
classify: Box::new(|chunk| {
let lower = chunk.text.to_ascii_lowercase();
let level = if lower.contains("error") || lower.contains("failed") {
LogLevel::Error
} else if lower.contains("warning") {
LogLevel::Warn
} else {
LogLevel::Info
};
Some(level)
}),
}
}
pub fn at(level: LogLevel) -> Self {
Self {
classify: Box::new(move |_| Some(level)),
}
}
pub fn silent() -> Self {
Self {
classify: Box::new(|_| None),
}
}
pub fn custom(f: impl Fn(&OutputChunk) -> Option<LogLevel> + 'static) -> Self {
Self {
classify: Box::new(f),
}
}
fn level_for(&self, chunk: &OutputChunk) -> Option<LogLevel> {
(self.classify)(chunk)
}
}
pub struct Command<'a> {
host: &'a dyn Host,
cmd: ProcessCommand,
}
impl<'a> Command<'a> {
pub(crate) fn new(host: &'a dyn Host, program: impl Into<String>) -> Self {
Self {
host,
cmd: ProcessCommand {
program: program.into(),
..Default::default()
},
}
}
#[must_use]
pub fn arg(mut self, a: impl Into<String>) -> Self {
self.cmd.args.push(a.into());
self
}
#[must_use]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.cmd.args.extend(args.into_iter().map(Into::into));
self
}
#[must_use]
pub fn cwd(mut self, dir: impl Into<String>) -> Self {
self.cmd.cwd = Some(dir.into());
self
}
#[must_use]
pub fn env(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
self.cmd.env.push((k.into(), v.into()));
self
}
#[must_use]
pub fn envs<I, K, V>(mut self, vars: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
self.cmd
.env
.extend(vars.into_iter().map(|(k, v)| (k.into(), v.into())));
self
}
#[must_use]
pub fn stdin(mut self, input: impl Into<String>) -> Self {
self.cmd.stdin = Some(input.into());
self
}
pub fn run(&self) -> Result<Output, String> {
let raw = self.host.process_run(&self.cmd)?;
Ok(Output::from_raw(raw))
}
pub fn stream(&self, handler: LineHandler) -> Result<Output, String> {
let mut child = self.host.process_spawn(&self.cmd)?;
let mut chunks = Vec::new();
while let Some(chunk) = child.next_line() {
if let Some(level) = handler.level_for(&chunk) {
self.host.log(level, &chunk.text);
}
chunks.push(chunk);
}
let exit_code = child.wait();
Ok(Output::from_raw(ProcessOutput { exit_code, chunks }))
}
pub fn spawn(&self) -> Result<Child<'a>, String> {
let handle = self.host.process_spawn(&self.cmd)?;
Ok(Child {
handle,
_marker: std::marker::PhantomData,
})
}
}
pub struct Child<'a> {
handle: Box<dyn ChildHandle>,
_marker: std::marker::PhantomData<&'a ()>,
}
impl Child<'_> {
pub fn next_line(&mut self) -> Option<OutputChunk> {
self.handle.next_line()
}
pub fn wait(&mut self) -> i32 {
self.handle.wait()
}
pub fn kill(&mut self) {
self.handle.kill();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::LogLevel;
use crate::testing::MockHost;
use crate::Context;
fn chunk(stream: StdioStream, text: &str) -> OutputChunk {
OutputChunk {
stream,
text: text.to_string(),
}
}
#[test]
fn run_captures_output_and_records_command() {
let host = MockHost::new().with_process_result(
0,
vec![
chunk(StdioStream::Stdout, "hello"),
chunk(StdioStream::Stderr, "note"),
],
);
let ctx = Context::new(&host, "/w".into(), "s".into());
let out = ctx.command("echo").arg("hello").run().unwrap();
assert!(out.success());
assert_eq!(out.stdout(), "hello");
assert_eq!(out.stderr(), "note");
let cmds = host.recorded_commands();
assert_eq!(cmds[0].program, "echo");
assert_eq!(cmds[0].args, vec!["hello".to_string()]);
}
#[test]
fn stream_routes_lines_through_severity_handler() {
let host = MockHost::new().with_process_result(
0,
vec![
chunk(StdioStream::Stdout, "building"),
chunk(StdioStream::Stderr, "ERROR: boom"),
chunk(StdioStream::Stdout, "warning: hmm"),
],
);
let ctx = Context::new(&host, "/w".into(), "s".into());
let out = ctx
.command("docker")
.stream(LineHandler::severity())
.unwrap();
assert_eq!(out.exit_code, 0);
assert_eq!(
host.logs(),
vec![
(LogLevel::Info, "building".to_string()),
(LogLevel::Error, "ERROR: boom".to_string()),
(LogLevel::Warn, "warning: hmm".to_string()),
]
);
}
#[test]
fn spawn_yields_lines_then_exit_code() {
let host = MockHost::new().with_process_result(
3,
vec![
chunk(StdioStream::Stdout, "a"),
chunk(StdioStream::Stdout, "b"),
],
);
let ctx = Context::new(&host, "/w".into(), "s".into());
let mut child = ctx.command("sh").spawn().unwrap();
let mut lines = Vec::new();
while let Some(c) = child.next_line() {
lines.push(c.text);
}
assert_eq!(lines, vec!["a".to_string(), "b".to_string()]);
assert_eq!(child.wait(), 3);
}
#[test]
fn spawn_failure_is_err_not_panic() {
let host = MockHost::new().with_process_error("program 'x' not permitted");
let ctx = Context::new(&host, "/w".into(), "s".into());
match ctx.command("x").run() {
Ok(_) => panic!("expected spawn failure"),
Err(e) => assert!(e.contains("not permitted"), "got: {e}"),
}
}
#[test]
fn silent_handler_suppresses_logs() {
let host =
MockHost::new().with_process_result(0, vec![chunk(StdioStream::Stdout, "quiet")]);
let ctx = Context::new(&host, "/w".into(), "s".into());
let out = ctx.command("echo").stream(LineHandler::silent()).unwrap();
assert_eq!(out.stdout(), "quiet");
assert!(host.logs().is_empty());
}
}