//! Internal subprocess helpers: `run`, `run_json`, `run_json_stdin`, and error mappers. //! //! All provider implementations that shell out to a CLI tool (hcloud, docker, oras) //! go through these helpers so error handling is uniform. use std::path::Path; use std::time::Duration; use tokio::io::AsyncWriteExt; use crate::ComputeError; /// Output from a subprocess invocation. #[derive(Debug)] pub enum CliOutput { /// Process exited with a non-zero status. NonZero { /// Exit code, if available. code: Option, /// Captured stderr. stderr: String, /// Captured stdout (may be partial — retained for debugging). #[allow(dead_code)] stdout: Vec, }, /// Process did not complete within the time budget. TimedOut(Duration), /// I/O error launching or communicating with the process. Io(std::io::Error), } impl std::fmt::Display for CliOutput { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { CliOutput::NonZero { code, stderr, .. } => { write!(f, "exit {:?}: {}", code, stderr.trim()) } CliOutput::TimedOut(d) => write!(f, "timed out after {d:?}"), CliOutput::Io(e) => write!(f, "I/O: {e}"), } } } /// Successful subprocess output (stdout + stderr captured). pub struct CliSuccess { /// Raw stdout bytes. pub stdout: Vec, /// Captured stderr (for debug logging). pub stderr: String, } /// Run `bin` with `args` within `budget`. Returns stdout on success. /// /// # Errors /// /// Returns `CliOutput` on non-zero exit, timeout, or I/O failure. pub async fn run( bin: &Path, args: &[String], budget: Duration, ) -> Result { let child = tokio::process::Command::new(bin) .args(args) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(CliOutput::Io)?; match tokio::time::timeout(budget, child.wait_with_output()).await { Err(_elapsed) => Err(CliOutput::TimedOut(budget)), Ok(Err(e)) => Err(CliOutput::Io(e)), Ok(Ok(out)) => { let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); if out.status.success() { Ok(CliSuccess { stdout: out.stdout, stderr }) } else { Err(CliOutput::NonZero { code: out.status.code(), stderr, stdout: out.stdout, }) } } } } /// Run `bin` with `args` and deserialize stdout as JSON type `T`. /// /// # Errors /// /// Returns `CliOutput` on non-zero exit, timeout, or I/O failure. /// Returns `Err` on JSON parse failure (wrapped as a `CliOutput::NonZero` with stderr set). pub async fn run_json( bin: &Path, args: &[String], budget: Duration, ) -> Result { let success = run(bin, args, budget).await?; tracing::debug!(stderr = %success.stderr, "cli subprocess stderr"); serde_json::from_slice(&success.stdout).map_err(|e| CliOutput::NonZero { code: None, stderr: format!("JSON parse failed: {e}"), stdout: success.stdout, }) } /// Run `bin` with `args`, writing `stdin_payload` to stdin, then deserialize stdout as `T`. /// /// Used by `ScriptProvider` to send a JSON envelope to a Nu script. /// /// # Errors /// /// Returns `CliOutput` on non-zero exit, timeout, or I/O failure. pub async fn run_json_stdin( bin: &Path, args: &[String], stdin_payload: Vec, budget: Duration, ) -> Result { let mut child = tokio::process::Command::new(bin) .args(args) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(CliOutput::Io)?; // Write stdin then close it so the process sees EOF. if let Some(mut stdin) = child.stdin.take() { stdin.write_all(&stdin_payload).await.map_err(CliOutput::Io)?; // Drop closes the handle. } match tokio::time::timeout(budget, child.wait_with_output()).await { Err(_elapsed) => Err(CliOutput::TimedOut(budget)), Ok(Err(e)) => Err(CliOutput::Io(e)), Ok(Ok(out)) => { let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); tracing::debug!(stderr = %stderr, "script provider stderr"); if out.status.success() { serde_json::from_slice(&out.stdout).map_err(|e| CliOutput::NonZero { code: None, stderr: format!("JSON parse failed: {e}"), stdout: out.stdout, }) } else { Err(CliOutput::NonZero { code: out.status.code(), stderr, stdout: out.stdout, }) } } } } // ── Error mapping helpers ───────────────────────────────────────────────────── /// Map a `CliOutput` to a `ComputeError` for spawn operations. pub fn cli_err_to_compute_err(e: CliOutput) -> ComputeError { cli_err_map(e, ComputeError::Spawn) } /// Map a `CliOutput` to a `ComputeError` for destroy operations. pub fn cli_err_to_compute_err_destroy(e: CliOutput) -> ComputeError { cli_err_map(e, ComputeError::Destroy) } /// Map a `CliOutput` to a `ComputeError` for status operations. pub fn cli_err_to_compute_err_status(e: CliOutput) -> ComputeError { cli_err_map(e, ComputeError::Status) } /// Map a `CliOutput` to a `ComputeError` for list operations. pub fn cli_err_to_compute_err_list(e: CliOutput) -> ComputeError { cli_err_map(e, ComputeError::List) } fn cli_err_map(e: CliOutput, fallback: impl Fn(String) -> ComputeError) -> ComputeError { match e { CliOutput::TimedOut(d) => ComputeError::Timeout(d), CliOutput::NonZero { ref stderr, .. } if stderr.contains("unauthorized") || stderr.contains("403") => { ComputeError::Auth(stderr.clone()) } CliOutput::NonZero { ref stderr, .. } if stderr.contains("could not connect") || stderr.contains("connection refused") || stderr.contains("no such host") => { ComputeError::ProviderUnavailable(stderr.clone()) } CliOutput::NonZero { stderr, .. } => fallback(stderr), CliOutput::Io(e) => ComputeError::ProviderUnavailable(e.to_string()), } }