Type Job::run_fn as a Lua-or-Rust enum
Built-in helpers like (ci.mirror …) want to do their work in plain
Rust without round-tripping through Lua. Switching Job::run_fn from
mlua::Function to a RunFn enum gives them a path; user-defined jobs
keep the Lua variant. No behavior change today.

Assisted-by: Claude Opus 4.7 via Claude Code
change mksopxxrkrxynnvwnmqstxyvtkmltwms
commit 49a2e1b53a83518c9df4c1716b96cfd499d2a21c
author Alpha Chen <alpha@kejadlen.dev>
date
parent tmnmtqrr
diff --git a/src/ci/lua.rs b/src/ci/lua.rs
index 11e0619..1e4ef96 100644
--- a/src/ci/lua.rs
+++ b/src/ci/lua.rs
@@ -15,7 +15,7 @@ use mlua::{IntoLua, Lua, LuaSerdeExt};
 
 use miette::NamedSource;
 
-use super::pipeline::{DefinitionError, Diagnostic, Job, PipelineError};
+use super::pipeline::{DefinitionError, Diagnostic, Job, PipelineError, RunFn};
 use crate::Result;
 use crate::fennel::Fennel;
 use crate::secret::SecretString;
@@ -155,7 +155,7 @@ fn register_job(
         .flatten()
         .map(|l| l as u32)
         .unwrap_or(0);
-    match Job::new(id, inputs, run_fn, line, &r.source) {
+    match Job::new(id, inputs, RunFn::Lua(run_fn), line, &r.source) {
         Ok(job) => r.jobs.borrow_mut().push(job),
         Err(e) => r.errors.borrow_mut().push(e),
     }
@@ -556,11 +556,16 @@ mod tests {
     use super::*;
 
     /// Consume the pipeline for its VM, build a minimal runtime,
-    /// and return the runtime and first job's run_fn.
+    /// and return the runtime and first job's Lua run_fn. Tests in
+    /// this module exercise the `RunFn::Lua` path; if the first job
+    /// turns out to be a `Rust` variant the test setup is wrong.
     fn rt(source: &str, secrets: HashMap<String, SecretString>) -> (Rc<Runtime>, mlua::Function) {
         let pipeline =
             super::super::pipeline::compile(source, "ci.fnl").expect("compile should succeed");
-        let run_fn = pipeline.jobs()[0].run_fn.clone();
+        let run_fn = match pipeline.jobs()[0].run_fn.clone() {
+            RunFn::Lua(f) => f,
+            RunFn::Rust(_) => panic!("expected RunFn::Lua for test setup"),
+        };
         let runtime = Rc::new(Runtime::for_test(pipeline, secrets));
         let _ = RuntimeHandle(runtime.clone())
             .into_lua(runtime.lua())
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index 9c67107..b1b9c48 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -92,10 +92,39 @@ pub struct Job {
     /// Span covering the `(ci.job …)` call site. Used as the label
     /// location for both per-job and post-graph diagnostics.
     pub(crate) span: SourceSpan,
-    /// The job's run function from the Lua VM.
-    /// Currently exercised only from tests until the runtime executor lands.
-    #[allow(dead_code)]
-    pub(crate) run_fn: mlua::Function,
+    /// What to run when the executor reaches this job.
+    pub(super) run_fn: RunFn,
+}
+
+/// A Rust-side run-fn: a closure invoked synchronously by the
+/// executor with the runtime in scope.
+pub(super) type RustRunFn = std::rc::Rc<dyn Fn(&super::lua::Runtime) -> Result<()>>;
+
+/// How a job runs at execute time.
+///
+/// `Lua` is the user case: a Fennel function the executor calls
+/// through the Lua VM, passing the runtime handle table. `Rust` is
+/// the built-in case: a closure that receives the runtime directly,
+/// used by helpers (e.g. `(ci.mirror …)`) that do their work in
+/// plain Rust without round-tripping through Lua.
+///
+/// Both variants are `Clone` so the executor can take an owned copy
+/// before invoking — `mlua::Function` is cheap to clone (a registry
+/// handle); the `Rc` makes the `Rust` variant cheap too.
+#[derive(Clone)]
+pub(super) enum RunFn {
+    Lua(mlua::Function),
+    #[allow(dead_code)] // Wired up by `(ci.mirror …)` and friends.
+    Rust(RustRunFn),
+}
+
+impl std::fmt::Debug for RunFn {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            RunFn::Lua(_) => f.debug_tuple("Lua").field(&"<lua function>").finish(),
+            RunFn::Rust(_) => f.debug_tuple("Rust").field(&"<rust closure>").finish(),
+        }
+    }
 }
 
 impl Job {
@@ -109,7 +138,7 @@ impl Job {
     pub(super) fn new(
         id: String,
         inputs: Vec<String>,
-        run_fn: mlua::Function,
+        run_fn: RunFn,
         line: u32,
         source: &str,
     ) -> std::result::Result<Self, DefinitionError> {
@@ -221,6 +250,18 @@ impl Pipeline {
     }
 }
 
+#[cfg(test)]
+impl Pipeline {
+    /// Replace the first job's run-fn — for tests that need to
+    /// exercise a `RunFn::Rust` execution path without building the
+    /// full helper machinery (which doesn't exist yet).
+    pub(super) fn replace_first_run_fn(&mut self, run_fn: RunFn) {
+        if let Some(job) = self.jobs.first_mut() {
+            job.run_fn = run_fn;
+        }
+    }
+}
+
 /// Build the dependency graph for `jobs`. Inputs that don't match a
 /// known job id are treated as source refs (e.g. `quire/push`) and
 /// don't get an edge — they're not nodes in this graph.
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 6c9e821..057f211 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -13,7 +13,7 @@ use jiff::Timestamp;
 use mlua::IntoLua;
 
 use super::lua::{Runtime, RuntimeHandle, ShOutput};
-use super::pipeline::Pipeline;
+use super::pipeline::{Pipeline, RunFn};
 use crate::display_chain;
 use crate::secret::SecretString;
 use crate::{Error, Result};
@@ -286,7 +286,8 @@ impl Run {
 
         self.transition(RunState::Active)?;
 
-        let mut failed_job = None;
+        let mut failed_job: Option<(String, Box<dyn std::error::Error + Send + Sync + 'static>)> =
+            None;
         for job_id in runtime.topo_order() {
             let run_fn = runtime
                 .job(job_id)
@@ -295,7 +296,16 @@ impl Run {
                 .clone();
 
             runtime.enter_job(job_id);
-            let result = run_fn.call::<mlua::Value>(rt_value.clone());
+            let result: std::result::Result<
+                (),
+                Box<dyn std::error::Error + Send + Sync + 'static>,
+            > = match run_fn {
+                RunFn::Lua(f) => f
+                    .call::<mlua::Value>(rt_value.clone())
+                    .map(|_| ())
+                    .map_err(|e| Box::new(e) as _),
+                RunFn::Rust(f) => f(&runtime).map_err(|e| Box::new(e) as _),
+            };
             runtime.leave_job();
 
             if let Err(e) = result {
@@ -313,10 +323,7 @@ impl Run {
 
         if let Some((job, source)) = failed_job {
             self.transition(RunState::Failed)?;
-            return Err(Error::JobFailed {
-                job,
-                source: Box::new(source),
-            });
+            return Err(Error::JobFailed { job, source });
         }
 
         self.transition(RunState::Complete)?;
@@ -1108,4 +1115,57 @@ mod tests {
             "expected registration error, got: {msg}"
         );
     }
+
+    #[test]
+    fn rust_run_fn_is_invoked_by_executor() {
+        use std::cell::Cell;
+
+        let (_dir, quire) = tmp_quire();
+        let runs = test_runs(&quire);
+        let run = runs.create(&test_meta()).expect("create");
+
+        let mut pipeline = load(
+            r#"(local ci (require :quire.ci))
+(ci.job :only [:quire/push] (fn [_] nil))"#,
+        );
+
+        let called = Rc::new(Cell::new(false));
+        let called_clone = called.clone();
+        pipeline.replace_first_run_fn(RunFn::Rust(Rc::new(move |_rt| {
+            called_clone.set(true);
+            Ok(())
+        })));
+
+        run.execute(pipeline, HashMap::new(), std::path::Path::new("."))
+            .expect("execute should succeed");
+        assert!(called.get(), "rust run-fn should have been called");
+    }
+
+    #[test]
+    fn rust_run_fn_errors_surface_as_job_failed() {
+        let (_dir, quire) = tmp_quire();
+        let runs = test_runs(&quire);
+        let run = runs.create(&test_meta()).expect("create");
+
+        let mut pipeline = load(
+            r#"(local ci (require :quire.ci))
+(ci.job :boom [:quire/push] (fn [_] nil))"#,
+        );
+
+        pipeline.replace_first_run_fn(RunFn::Rust(Rc::new(|_rt| {
+            Err(crate::Error::Git("simulated rust failure".into()))
+        })));
+
+        let err = run
+            .execute(pipeline, HashMap::new(), std::path::Path::new("."))
+            .expect_err("expected failure");
+        let Error::JobFailed { job, source } = err else {
+            panic!("expected JobFailed, got: {err:?}");
+        };
+        assert_eq!(job, "boom");
+        assert!(
+            source.to_string().contains("simulated rust failure"),
+            "expected source to surface rust error, got: {source}"
+        );
+    }
 }
diff --git a/src/error.rs b/src/error.rs
index 18b01b7..0437018 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -33,8 +33,13 @@ pub enum Error {
     #[error("job '{job}' failed")]
     JobFailed {
         job: String,
+        // Boxed `dyn Error` rather than a concrete type so both Lua
+        // and Rust run-fns can land here without an extra wrapper —
+        // an mlua::Error from `RunFn::Lua`, a crate::Error from
+        // `RunFn::Rust`, both walk through `display_chain` the same
+        // way.
         #[source]
-        source: Box<mlua::Error>,
+        source: Box<dyn std::error::Error + Send + Sync + 'static>,
     },
 
     #[error("git error: {0}")]