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
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}")]