Add Fennel::load_config_str; typed MirrorError; real errors in mirror
quire-core:
- Add Fennel::load_config_str(source, name) — like load_config but reads
  from a string, useful for content piped from git show

quire-server/quire/mod.rs:
- repo_config uses load_config_str, keeping the ".quire/config.fnl" label
  in error messages (no temp file needed)

quire-server/mirror.rs:
- Replace miette::miette! and io::Error::other string wrappers with a
  typed MirrorError enum (RepoNotFound, TokenNotConfigured, PushFailed,
  App, Io); mirror_ref and push_to_mirror return Result<(), MirrorError>

https://claude.ai/code/session_01MtUMXi7Z3GCDWQFY8puWpu
change
commit 1bcaea9dbb20fa634fd637962b273117eecc3e6f
author Claude <noreply@anthropic.com>
date
parent 47c6beff
diff --git a/quire-core/src/fennel.rs b/quire-core/src/fennel.rs
index 58ab385..564ff99 100644
--- a/quire-core/src/fennel.rs
+++ b/quire-core/src/fennel.rs
@@ -189,6 +189,17 @@ impl Fennel {
     {
         Self::new()?.load_file(path)
     }
+
+    /// Create a fresh Fennel VM and parse `source` into `T`.
+    ///
+    /// Like [`load_config`] but reads from a string rather than a file.
+    /// `name` is used in error messages (e.g. `".quire/config.fnl"`).
+    pub fn load_config_str<T>(source: &str, name: &str) -> Result<T, FennelError>
+    where
+        T: serde::de::DeserializeOwned,
+    {
+        Self::new()?.load_string(source, name)
+    }
 }
 
 impl FennelError {
diff --git a/quire-server/src/mirror.rs b/quire-server/src/mirror.rs
index 983bb3f..689782a 100644
--- a/quire-server/src/mirror.rs
+++ b/quire-server/src/mirror.rs
@@ -16,6 +16,24 @@ struct MirrorErrors {
     errors: Vec<miette::Report>,
 }
 
+#[derive(Debug, Error, Diagnostic)]
+enum MirrorError {
+    #[error("repo not found on disk: {0}")]
+    RepoNotFound(String),
+
+    #[error("mirror-token not configured")]
+    TokenNotConfigured,
+
+    #[error("git push to {url} failed: {stderr}")]
+    PushFailed { url: String, stderr: String },
+
+    #[error(transparent)]
+    App(#[from] crate::Error),
+
+    #[error(transparent)]
+    Io(#[from] std::io::Error),
+}
+
 /// Mirror updated refs to a configured remote.
 ///
 /// Reads `github.mirror-token` from global config for auth. For each updated
@@ -25,7 +43,7 @@ pub fn trigger(quire: &Quire, event: &PushEvent) -> miette::Result<()> {
     let repo = match quire.repo(&event.repo) {
         Ok(r) if r.exists() => r,
         Ok(_) => {
-            return Err(miette::miette!("repo not found on disk: {}", event.repo));
+            return Err(MirrorError::RepoNotFound(event.repo.clone()).into());
         }
         Err(e) => return Err(e),
     };
@@ -42,7 +60,7 @@ pub fn trigger(quire: &Quire, event: &PushEvent) -> miette::Result<()> {
 
     for push_ref in event.updated_refs() {
         if let Err(e) = mirror_ref(&repo, push_ref, mirror_token.as_deref()) {
-            errors.push(miette::miette!("{}: {e}", push_ref.ref_name));
+            errors.push(miette::Report::from(e));
         }
     }
 
@@ -58,13 +76,12 @@ fn mirror_ref(
     repo: &crate::quire::Repo,
     push_ref: &PushRef,
     token: Option<&str>,
-) -> crate::Result<()> {
+) -> Result<(), MirrorError> {
     let repo_config = repo.repo_config(&push_ref.new_sha)?;
     let Some(mirror_url) = repo_config.github.mirror else {
         return Ok(());
     };
-    let token = token
-        .ok_or_else(|| crate::Error::Io(std::io::Error::other("mirror-token not configured")))?;
+    let token = token.ok_or(MirrorError::TokenNotConfigured)?;
     push_to_mirror(repo, &push_ref.ref_name, &mirror_url, token)
 }
 
@@ -73,7 +90,7 @@ fn push_to_mirror(
     ref_name: &str,
     mirror_url: &str,
     token: &str,
-) -> crate::Result<()> {
+) -> Result<(), MirrorError> {
     // Force-push the ref to the mirror. The `+` prefix allows rewrites.
     let refspec = format!("+{ref_name}:{ref_name}");
 
@@ -91,10 +108,10 @@ fn push_to_mirror(
         .output()?;
 
     if !out.status.success() {
-        let stderr = String::from_utf8_lossy(&out.stderr);
-        return Err(crate::Error::Io(std::io::Error::other(format!(
-            "git push to {mirror_url} failed: {stderr}"
-        ))));
+        return Err(MirrorError::PushFailed {
+            url: mirror_url.to_string(),
+            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
+        });
     }
 
     tracing::info!(ref_name, mirror_url, "mirror: push succeeded");
diff --git a/quire-server/src/quire/mod.rs b/quire-server/src/quire/mod.rs
index 608a824..589b26e 100644
--- a/quire-server/src/quire/mod.rs
+++ b/quire-server/src/quire/mod.rs
@@ -163,26 +163,16 @@ impl Repo {
         let path = format!("{sha}:.quire/config.fnl");
 
         // cat-file -e: exit 0 if the object exists, non-zero if absent.
-        let exists = self.git(&["cat-file", "-e", &path]).status()?.success();
-        if !exists {
+        if !self.git(&["cat-file", "-e", &path]).status()?.success() {
             return Ok(RepoConfig::default());
         }
 
-        let output = self
+        let out = self
             .git(&["show", &path])
             .stdout(std::process::Stdio::piped())
-            .stderr(std::process::Stdio::piped())
             .output()?;
-
-        if !output.status.success() {
-            let stderr = String::from_utf8_lossy(&output.stderr);
-            return Err(Error::Io(std::io::Error::other(format!(
-                "failed to read .quire/config.fnl at {sha}: {stderr}"
-            ))));
-        }
-
-        let source = String::from_utf8(output.stdout)?;
-        Ok(Fennel::new()?.load_string(&source, ".quire/config.fnl")?)
+        let source = String::from_utf8(out.stdout)?;
+        Ok(Fennel::load_config_str(&source, ".quire/config.fnl")?)
     }
 
     /// The base directory for CI runs (`runs/<repo>/`).