Skip to main content

zag_agent/
process.rs

1use anyhow::Result;
2use log::debug;
3use std::process::Stdio;
4use tokio::io::AsyncReadExt;
5use tokio::process::Command;
6
7#[cfg(test)]
8#[path = "process_tests.rs"]
9mod tests;
10
11/// Structured error from a failed subprocess.
12///
13/// Wraps the exit code and stderr so callers can inspect them
14/// (e.g. to populate `AgentOutput.exit_code` / `error_message`).
15#[derive(Debug, Clone)]
16pub struct ProcessError {
17    /// The process exit code, if available.
18    pub exit_code: Option<i32>,
19    /// Captured stderr text (may be empty).
20    pub stderr: String,
21    /// Name of the agent / command that failed.
22    pub agent_name: String,
23}
24
25impl std::fmt::Display for ProcessError {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        if self.stderr.is_empty() {
28            write!(
29                f,
30                "{} command failed with exit code {:?}",
31                self.agent_name, self.exit_code
32            )
33        } else {
34            write!(f, "{}", self.stderr)
35        }
36    }
37}
38
39impl std::error::Error for ProcessError {}
40
41/// Read stderr from a child process handle into a trimmed String.
42async fn read_stderr(handle: Option<tokio::process::ChildStderr>) -> String {
43    if let Some(stderr) = handle {
44        let mut buf = Vec::new();
45        let mut reader = tokio::io::BufReader::new(stderr);
46        let _ = reader.read_to_end(&mut buf).await;
47        String::from_utf8_lossy(&buf).trim().to_string()
48    } else {
49        String::new()
50    }
51}
52
53/// Log non-empty stderr text.
54pub fn log_stderr_text(stderr: &str) {
55    if !stderr.is_empty() {
56        for line in stderr.lines() {
57            debug!("[STDERR] {}", line);
58        }
59    }
60}
61
62/// Log stderr and bail on non-zero exit status.
63///
64/// Returns `Ok(())` on success. On failure, logs stderr to file and
65/// returns an error containing the stderr text (or the exit status if stderr is empty).
66pub fn check_exit_status(
67    status: std::process::ExitStatus,
68    stderr: &str,
69    agent_name: &str,
70) -> Result<()> {
71    debug!("{} process exited with status: {}", agent_name, status);
72    if status.success() {
73        return Ok(());
74    }
75    Err(ProcessError {
76        exit_code: status.code(),
77        stderr: stderr.to_string(),
78        agent_name: agent_name.to_string(),
79    }
80    .into())
81}
82
83/// Handle stderr logging and exit status checking for a completed `Output`.
84///
85/// Logs any stderr to file, then bails if exit status is non-zero.
86pub fn handle_output(output: &std::process::Output, agent_name: &str) -> Result<()> {
87    let stderr_text = String::from_utf8_lossy(&output.stderr);
88    let stderr_text = stderr_text.trim();
89    log_stderr_text(stderr_text);
90    check_exit_status(output.status, stderr_text, agent_name)
91}
92
93/// Run a command capturing stdout and stderr, returning stdout text on success.
94///
95/// Stdin is inherited. On failure, stderr is included in the error message.
96pub async fn run_captured(cmd: &mut Command, agent_name: &str) -> Result<String> {
97    debug!("{}: running with captured stdout/stderr", agent_name);
98    cmd.stdin(Stdio::inherit())
99        .stdout(Stdio::piped())
100        .stderr(Stdio::piped());
101
102    let output = cmd.output().await?;
103    debug!(
104        "{}: captured {} bytes stdout, {} bytes stderr",
105        agent_name,
106        output.stdout.len(),
107        output.stderr.len()
108    );
109    handle_output(&output, agent_name)?;
110    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
111}
112
113/// Run a command with stderr captured.
114///
115/// - On success (exit code 0): captured stderr is logged to file only
116/// - On failure (exit code != 0): captured stderr is logged to file AND returned in the error
117///
118/// Stdout and stdin should be configured by the caller before calling this function.
119/// This function only sets stderr to piped.
120pub async fn run_with_captured_stderr(cmd: &mut Command) -> Result<()> {
121    debug!("Running command with captured stderr");
122    cmd.stderr(Stdio::piped());
123
124    let mut child = cmd.spawn()?;
125    let stderr_handle = child.stderr.take();
126    let status = child.wait().await?;
127    let stderr_text = read_stderr(stderr_handle).await;
128
129    log_stderr_text(&stderr_text);
130    check_exit_status(status, &stderr_text, "Command")
131}
132
133/// Spawn a command with stderr captured, but stdout piped for reading.
134///
135/// Returns the child process. The caller is responsible for reading stdout
136/// and calling `wait_with_stderr()` when done.
137pub async fn spawn_with_captured_stderr(cmd: &mut Command) -> Result<tokio::process::Child> {
138    debug!("Spawning command with captured stderr");
139    cmd.stderr(Stdio::piped());
140    let child = cmd.spawn()?;
141    Ok(child)
142}
143
144/// Wait for a child process and handle its captured stderr.
145///
146/// - On success: stderr logged to file only
147/// - On failure: stderr logged to file AND returned in the error
148pub async fn wait_with_stderr(mut child: tokio::process::Child) -> Result<()> {
149    let stderr_handle = child.stderr.take();
150    let status = child.wait().await?;
151    let stderr_text = read_stderr(stderr_handle).await;
152
153    log_stderr_text(&stderr_text);
154    check_exit_status(status, &stderr_text, "Command")
155}