Replace map_err string wrapping with typed error variants
Added From impls for serde_yaml_ng::Error and FromUtf8Error so ?
handles the conversion. Replaced map_err(Error::Io) with bare ? since
Io already has #[from]. Added comments on SecretResolve explaining
why it still uses a string (OnceLock + non-Clone io::Error) and
noting once_cell_try as the fix path.

Assisted-by: GLM-5.1 via pi
change xuzkmwpkstlpmpxxxyxpkvqvosvmprtx
commit 8e36ed01eea00abe5434ebbea4ec046f425d6541
author Alpha Chen <alpha@kejadlen.dev>
date
parent zquxutwx
diff --git a/src/ci.rs b/src/ci.rs
index ac95be7..709e36e 100644
--- a/src/ci.rs
+++ b/src/ci.rs
@@ -653,20 +653,19 @@ async fn dispatch_mirror(quire: &crate::Quire, repo: crate::quire::Repo, event:
     }
 }
 
+/// Write a serializable value to a YAML file atomically (temp file + rename).
 fn write_yaml<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
     let tmp_path = path.with_extension("yml.tmp");
     let f = fs_err::File::create(&tmp_path)?;
-    serde_yaml_ng::to_writer(std::io::BufWriter::new(f), value)
-        .map_err(|e| crate::Error::Io(std::io::Error::other(format!("yaml write error: {e}"))))?;
+    serde_yaml_ng::to_writer(std::io::BufWriter::new(f), value)?;
     fs_err::rename(&tmp_path, path)?;
     Ok(())
 }
 
-/// Read a value from a YAML file.
+/// Read a deserializable value from a YAML file.
 fn read_yaml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
     let f = fs_err::File::open(path)?;
-    serde_yaml_ng::from_reader(std::io::BufReader::new(f))
-        .map_err(|e| crate::Error::Io(std::io::Error::other(format!("yaml read error: {e}"))))
+    Ok(serde_yaml_ng::from_reader(std::io::BufReader::new(f))?)
 }
 
 #[cfg(test)]
diff --git a/src/error.rs b/src/error.rs
index 70fe5bc..7956c64 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -10,6 +10,8 @@ pub enum Error {
     #[error("io error: {0}")]
     Io(#[from] std::io::Error),
 
+    // Stored as a string because `OnceLock` in `SecretString::reveal` caches
+    // the error and `std::io::Error` is not `Clone`. See `secret.rs` for details.
     #[error("secret resolution failed: {0}")]
     SecretResolve(String),
 
@@ -29,6 +31,12 @@ pub enum Error {
     #[error("git error: {0}")]
     Git(String),
 
+    #[error(transparent)]
+    Yaml(#[from] serde_yaml_ng::Error),
+
+    #[error(transparent)]
+    Utf8(#[from] std::string::FromUtf8Error),
+
     #[allow(dead_code)]
     #[error("event socket error: {0}")]
     EventSocket(String),
diff --git a/src/quire.rs b/src/quire.rs
index d98b454..e553e80 100644
--- a/src/quire.rs
+++ b/src/quire.rs
@@ -170,8 +170,7 @@ impl Repo {
             .git(&["show", &format!("{sha}:.quire/ci.fnl")])
             .stdout(std::process::Stdio::piped())
             .stderr(std::process::Stdio::piped())
-            .output()
-            .map_err(|e| crate::Error::Git(format!("failed to read ci.fnl: {e}")))?;
+            .output()?;
 
         if !output.status.success() {
             let stderr = String::from_utf8_lossy(&output.stderr);
@@ -180,8 +179,7 @@ impl Repo {
             )));
         }
 
-        String::from_utf8(output.stdout)
-            .map_err(|e| crate::Error::Git(format!("ci.fnl is not valid UTF-8: {e}")))
+        Ok(String::from_utf8(output.stdout)?)
     }
 
     /// Push `main` to the configured mirror, injecting the GitHub token via
@@ -208,8 +206,7 @@ impl Repo {
             .env("GIT_CONFIG_VALUE_0", github_auth_header(token))
             .stdout(std::process::Stdio::null())
             .stderr(std::process::Stdio::null())
-            .status()
-            .map_err(crate::Error::Io)?;
+            .status()?;
 
         if !status.success() {
             return Err(crate::Error::Git(format!("push to {} failed", mirror.url)));
@@ -233,30 +230,21 @@ impl Repo {
             .git(&["rev-parse", "--verify", "HEAD"])
             .stdout(std::process::Stdio::null())
             .stderr(std::process::Stdio::null())
-            .status()
-            .map_err(crate::Error::Io)?
+            .status()?
             .success();
 
         if !has_head {
             return Ok(RepoConfig::default());
         }
 
-        let output = self
-            .git(&["show", "HEAD:.quire/config.fnl"])
-            .output()
-            .map_err(crate::Error::Io)?;
+        let output = self.git(&["show", "HEAD:.quire/config.fnl"]).output()?;
 
         if !output.status.success() {
             // HEAD exists but the file doesn't — not an error.
             return Ok(RepoConfig::default());
         }
 
-        let source = String::from_utf8(output.stdout).map_err(|e| {
-            crate::Error::Io(std::io::Error::new(
-                std::io::ErrorKind::InvalidData,
-                format!("config is not valid UTF-8: {e}"),
-            ))
-        })?;
+        let source = String::from_utf8(output.stdout)?;
 
         let fennel = Fennel::new()?;
         Ok(fennel.load_string(&source, "HEAD:.quire/config.fnl")?)
diff --git a/src/secret.rs b/src/secret.rs
index e439673..3ac5a1a 100644
--- a/src/secret.rs
+++ b/src/secret.rs
@@ -40,8 +40,13 @@ impl SecretString {
     /// The resolved secret value.
     ///
     /// For the file variant, reads from disk on first call and caches the
-    /// result. Returns a typed error if the file is missing or unreadable.
-    /// Errors are also cached — subsequent calls return the same error.
+    /// result. Errors are also cached — subsequent calls return the same error.
+    ///
+    /// The error is stored as a formatted string inside `OnceLock` because
+    /// `std::io::Error` is not `Clone`, and `OnceLock::get_or_init` requires
+    /// the closure output to be `Sized` + ownable. Once `once_cell_try`
+    /// stabilizes (allowing `OnceLock::get_or_try_init` with a separate error
+    /// type), we can store a structured error instead of a string.
     pub fn reveal(&self) -> crate::Result<&str> {
         match &self.0 {
             SecretSource::Plain(s) => Ok(s.as_str()),