Make CI io errors self-describing
Two fixes for uninformative error messages:

(1) Error::Io used \"io error: {0}\" with #[from] so display_chain
printed the io::Error twice (once via Display and once via .source()).
Dropped the {0} so the chain walk handles it.

(2) Added CommandSpawnFailed variant that captures the program name
and cwd at the Cmd::run boundary. Previously a missing binary or bad
cwd surfaced as bare \"No such file or directory\" with no clue which
program or directory.

Assisted-by: GLM-5.1 via pi
change tkvnotqpxoysmsknlnpusonzxpqlunto
commit 322492b3a5e1ce87eb7f81cab57af261f5d9f916
author Alpha Chen <alpha@kejadlen.dev>
date
parent toztnkss
diff --git a/src/ci/error.rs b/src/ci/error.rs
index 2995401..d2912d8 100644
--- a/src/ci/error.rs
+++ b/src/ci/error.rs
@@ -10,7 +10,7 @@ use crate::secret;
 /// Errors produced by CI operations.
 #[derive(Debug, thiserror::Error, Diagnostic)]
 pub enum Error {
-    #[error("io error: {0}")]
+    #[error("io error")]
     Io(#[from] std::io::Error),
 
     #[error(transparent)]
@@ -73,6 +73,14 @@ pub enum Error {
     #[error(transparent)]
     Secret(#[from] secret::Error),
 
+    #[error("command spawn failed: {program} in {cwd}")]
+    CommandSpawnFailed {
+        program: String,
+        cwd: std::path::PathBuf,
+        #[source]
+        source: std::io::Error,
+    },
+
     #[error("unknown secret: {0:?}")]
     UnknownSecret(String),
 }
diff --git a/src/ci/runtime.rs b/src/ci/runtime.rs
index 84d4d46..c529cd7 100644
--- a/src/ci/runtime.rs
+++ b/src/ci/runtime.rs
@@ -215,12 +215,28 @@ impl Runtime {
     pub(super) fn sh(&self, cmd: Cmd, opts: ShOpts) -> super::error::Result<ShOutput> {
         let started_at = Timestamp::now();
         let output = match self.docker_target() {
-            None => cmd.run(opts, &self.workspace)?,
+            None => {
+                let program = cmd.program().to_string();
+                cmd.run(opts, &self.workspace).map_err(|e| {
+                    super::error::Error::CommandSpawnFailed {
+                        program,
+                        cwd: self.workspace.clone(),
+                        source: e,
+                    }
+                })?
+            }
             Some((container_id, work_dir)) => {
                 let wrapped = Cmd::wrap_in_docker_exec(cmd, container_id, work_dir, &opts);
+                let program = wrapped.program().to_string();
                 // env was embedded into the docker exec argv; clear it
                 // so it isn't also set on the host `docker` process.
-                wrapped.run(ShOpts::default(), &self.workspace)?
+                wrapped
+                    .run(ShOpts::default(), &self.workspace)
+                    .map_err(|e| super::error::Error::CommandSpawnFailed {
+                        program,
+                        cwd: self.workspace.clone(),
+                        source: e,
+                    })?
             }
         };
         let finished_at = Timestamp::now();
@@ -390,6 +406,13 @@ impl From<Cmd> for std::process::Command {
 }
 
 impl Cmd {
+    pub fn program(&self) -> &str {
+        match self {
+            Cmd::Shell(_) => "sh",
+            Cmd::Argv { program, .. } => program,
+        }
+    }
+
     /// Spawn this command with the given options, blocking until exit,
     /// and capture the result. Inherits the runner's env with
     /// `opts.env` merged on top. `cwd` becomes the child's working