Move Lua bridge into its own module
Pipeline.rs was carrying both the domain types (Job, Pipeline,
ValidationError, the validation rules) and every byte of mlua/Fennel
plumbing — the CiModule, the three primitive bodies, the sh shapes.
Split the latter into a private ci::lua module so each file has a
single concern: pipeline owns the graph, lua owns the bridge.

The pipeline::load entry point now delegates evaluation to lua::parse;
nothing else changes externally. Per-primitive tests follow their code
to the new module; validation tests stay with the validator.

Assisted-by: Claude Opus 4.7 via Claude Code
change rxpmmrtzplusllkxxqssuozqvunznwmk
commit 56bfb704e9ffd4cbf14cd1fd0ff4b5fcfe30ea1e
author Alpha Chen <alpha@kejadlen.dev>
date
parent ztzwrvwk
diff --git a/src/ci/lua.rs b/src/ci/lua.rs
new file mode 100644
index 0000000..410f412
--- /dev/null
+++ b/src/ci/lua.rs
@@ -0,0 +1,396 @@
+//! Lua bridge for `ci.fnl`: the `quire.ci` module exposed to Fennel
+//! scripts and the runtime primitives (`job`, `secret`, `sh`).
+//!
+//! All mlua/Fennel interaction lives here. The pipeline module calls
+//! [`parse`] to evaluate a script and collect the registered jobs;
+//! everything else (the `quire.ci` table, the primitive bodies, the
+//! Lua-side data shapes) is internal.
+
+use std::cell::RefCell;
+use std::collections::HashMap;
+use std::rc::Rc;
+
+use mlua::{Lua, LuaSerdeExt};
+
+use super::pipeline::{Job, ValidationError};
+use crate::Result;
+use crate::fennel::Fennel;
+use crate::secret::SecretString;
+
+/// Evaluate `source` with the `quire.ci` module bound and collect the
+/// registration results — one `Result` per `(ci.job …)` call. Pre-graph
+/// rules run inside the callback, so a single bad job does not abort
+/// the rest of the script.
+pub(super) fn parse(
+    fennel: &Fennel,
+    source: &str,
+    filename: &str,
+    display: &str,
+    secrets: HashMap<String, SecretString>,
+) -> Result<Vec<std::result::Result<Job, ValidationError>>> {
+    let jobs = Rc::new(RefCell::new(Vec::new()));
+    let src = Rc::new(source.to_string());
+    let secrets = Rc::new(secrets);
+
+    fennel.eval_raw(source, filename, display, |lua| {
+        let module = CiModule {
+            jobs: jobs.clone(),
+            source: src.clone(),
+            secrets: secrets.clone(),
+        }
+        .install(lua)?;
+        let loaded: mlua::Table = lua.globals().get::<mlua::Table>("package")?.get("loaded")?;
+        loaded.set("quire.ci", module)?;
+        Ok(())
+    })?;
+
+    Ok(jobs.take())
+}
+
+/// The `quire.ci` module exposed to Fennel scripts via `require`.
+///
+/// `install` stows the module on the Lua VM via `set_app_data`, then
+/// builds a plain table whose entries are bare functions that look the
+/// module back up at call time. Both `(ci.job …)` field access and
+/// `(local {: job : secret} (require :quire.ci))` destructuring work.
+///
+/// ```fennel
+/// (local ci (require :quire.ci))
+/// (ci.job :build [:quire/push] (fn [_] nil))
+/// (ci.secret :github_token)
+/// ```
+struct CiModule {
+    jobs: Rc<RefCell<Vec<std::result::Result<Job, ValidationError>>>>,
+    source: Rc<String>,
+    secrets: Rc<HashMap<String, SecretString>>,
+}
+
+impl CiModule {
+    /// Install the module on `lua` as app data and return the
+    /// `quire.ci` table. The registered functions error at call time
+    /// if the module isn't installed first.
+    fn install(self, lua: &Lua) -> mlua::Result<mlua::Table> {
+        lua.set_app_data(self);
+        let table = lua.create_table()?;
+        table.set("job", lua.create_function(register_job)?)?;
+        table.set("secret", lua.create_function(lookup_secret)?)?;
+        table.set("sh", lua.create_function(run_sh)?)?;
+        Ok(table)
+    }
+}
+
+/// Body of `(ci.job id inputs run-fn)`. Captures the call-site line
+/// from the Lua debug stack so per-job validation errors carry a span
+/// pointing back at the user's source.
+fn register_job(
+    lua: &Lua,
+    (id, inputs, run_fn): (String, Vec<String>, mlua::Function),
+) -> mlua::Result<()> {
+    let m = lua
+        .app_data_ref::<CiModule>()
+        .ok_or_else(|| mlua::Error::external("quire.ci module not installed on Lua VM"))?;
+    let line = lua
+        .inspect_stack(1, |d| d.current_line())
+        .flatten()
+        .map(|l| l as u32)
+        .unwrap_or(0);
+    m.jobs
+        .borrow_mut()
+        .push(Job::new(id, inputs, run_fn, line, &m.source));
+    Ok(())
+}
+
+/// Body of `(ci.secret name)`. Errors as a Lua error if the name is
+/// undeclared or the file form fails to read.
+fn lookup_secret(lua: &Lua, name: String) -> mlua::Result<String> {
+    let m = lua
+        .app_data_ref::<CiModule>()
+        .ok_or_else(|| mlua::Error::external("quire.ci module not installed on Lua VM"))?;
+    let secret = m
+        .secrets
+        .get(&name)
+        .ok_or_else(|| mlua::Error::external(crate::Error::UnknownSecret(name)))?;
+    secret
+        .reveal()
+        .map(|s| s.to_string())
+        .map_err(mlua::Error::external)
+}
+
+/// The two valid shapes of `cmd` for `(ci.sh cmd …)`. A bare string
+/// runs under `sh -c`; a sequence runs as argv with no shell.
+#[derive(serde::Deserialize)]
+#[serde(untagged)]
+enum Cmd {
+    Shell(String),
+    Argv(Vec<String>),
+}
+
+impl From<Cmd> for std::process::Command {
+    fn from(cmd: Cmd) -> Self {
+        match cmd {
+            Cmd::Shell(s) => {
+                let mut c = std::process::Command::new("sh");
+                c.arg("-c").arg(s);
+                c
+            }
+            Cmd::Argv(argv) => {
+                let mut c = std::process::Command::new(&argv[0]);
+                c.args(&argv[1..]);
+                c
+            }
+        }
+    }
+}
+
+impl Cmd {
+    /// 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.
+    fn run(self, opts: ShOpts) -> std::io::Result<Output> {
+        let mut command: std::process::Command = self.into();
+        for (k, v) in opts.env {
+            command.env(k, v);
+        }
+        if let Some(cwd) = opts.cwd {
+            command.current_dir(cwd);
+        }
+        let output = command.output()?;
+        Ok(Output {
+            exit: output.status.code().unwrap_or(-1),
+            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
+            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
+        })
+    }
+}
+
+impl mlua::FromLua for Cmd {
+    fn from_lua(value: mlua::Value, lua: &Lua) -> mlua::Result<Self> {
+        // Pre-check the Lua type so wrong-shape inputs get specific
+        // FromLuaConversionError messages — serde's untagged dispatch
+        // would otherwise just say "data did not match any variant".
+        match &value {
+            mlua::Value::String(_) => lua.from_value(value),
+            mlua::Value::Table(t) if t.raw_len() == 0 => {
+                Err(mlua::Error::FromLuaConversionError {
+                    from: "table",
+                    to: "Cmd".into(),
+                    message: Some("ci.sh: argv list is empty".into()),
+                })
+            }
+            mlua::Value::Table(_) => lua.from_value(value),
+            other => Err(mlua::Error::FromLuaConversionError {
+                from: other.type_name(),
+                to: "Cmd".into(),
+                message: Some("ci.sh: cmd must be a string or sequence of strings".into()),
+            }),
+        }
+    }
+}
+
+/// The optional `opts` table for `(ci.sh cmd opts?)`. Unknown keys
+/// fail closed so typos surface rather than being silently ignored.
+#[derive(Default, serde::Deserialize)]
+#[serde(default, deny_unknown_fields)]
+struct ShOpts {
+    env: HashMap<String, String>,
+    cwd: Option<String>,
+}
+
+impl mlua::FromLua for ShOpts {
+    fn from_lua(value: mlua::Value, lua: &Lua) -> mlua::Result<Self> {
+        lua.from_value(value)
+    }
+}
+
+/// The captured outcome of running a process — what `(ci.sh …)`
+/// returns. Crosses the boundary as plain serde data: `lua.to_value`
+/// on the way out, `lua.from_value` on the way in.
+///
+/// A non-zero exit is reported in `:exit`, not raised as a Lua error —
+/// matches the shape `(container …)` will eventually use so callers can
+/// branch on it.
+#[derive(serde::Serialize, serde::Deserialize)]
+struct Output {
+    exit: i32,
+    stdout: String,
+    stderr: String,
+}
+
+/// Body of `(ci.sh cmd opts?)`. Glue between the Lua call and
+/// `Cmd::run` — defaults the opts and converts both directions.
+fn run_sh(lua: &Lua, (cmd, opts): (Cmd, Option<ShOpts>)) -> mlua::Result<mlua::Value> {
+    let output = cmd
+        .run(opts.unwrap_or_default())
+        .map_err(mlua::Error::external)?;
+    lua.to_value(&output)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::super::pipeline::load;
+    use super::*;
+
+    fn fennel() -> Fennel {
+        Fennel::new().expect("Fennel::new() should succeed")
+    }
+
+    #[test]
+    fn ci_secret_returns_resolved_value() {
+        let f = fennel();
+        let mut secrets = HashMap::new();
+        secrets.insert(
+            "github_token".to_string(),
+            SecretString::from_plain("ghp_test_value"),
+        );
+        let source = r#"(local ci (require :quire.ci))
+(ci.job :grab [:quire/push] (fn [_] (ci.secret :github_token)))"#;
+        let pipeline =
+            load(&f, source, "ci.fnl", "ci.fnl", secrets).expect("load should succeed");
+        let token: String = pipeline.jobs()[0]
+            .run_fn
+            .call(())
+            .expect("run_fn should return the secret value");
+        assert_eq!(token, "ghp_test_value");
+    }
+
+    #[test]
+    fn ci_secret_errors_for_unknown_name() {
+        let f = fennel();
+        let source = r#"(local ci (require :quire.ci))
+(ci.job :grab [:quire/push] (fn [_] (ci.secret :missing)))"#;
+        let pipeline = load(&f, source, "ci.fnl", "ci.fnl", HashMap::new())
+            .expect("load should succeed");
+        let err = pipeline.jobs()[0]
+            .run_fn
+            .call::<mlua::Value>(())
+            .unwrap_err();
+        let msg = err.to_string();
+        assert!(
+            msg.contains("unknown secret") && msg.contains("missing"),
+            "expected unknown-secret error mentioning the name, got: {msg}"
+        );
+    }
+
+    /// Build a pipeline whose single job's run-fn invokes `(ci.sh …)`,
+    /// invoke it, and decode the resulting Lua table as Output through
+    /// the same VM via `lua.from_value`. Owned data, so the Fennel VM
+    /// can drop without a use-after-free.
+    fn run_sh_via_job(source: &str) -> Output {
+        let f = fennel();
+        let pipeline =
+            load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
+        let value: mlua::Value = pipeline.jobs()[0]
+            .run_fn
+            .call(())
+            .expect("ci.sh call should return a value");
+        f.lua().from_value(value).expect("decode Output")
+    }
+
+    #[test]
+    fn ci_sh_runs_argv_and_captures_stdout() {
+        let r = run_sh_via_job(
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push] (fn [_] (ci.sh ["echo" "hello"])))"#,
+        );
+        assert_eq!(r.exit, 0);
+        assert_eq!(r.stdout, "hello\n");
+        assert!(r.stderr.is_empty());
+    }
+
+    #[test]
+    fn ci_sh_runs_string_under_shell() {
+        let r = run_sh_via_job(
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push] (fn [_] (ci.sh "echo hello | tr a-z A-Z")))"#,
+        );
+        assert_eq!(r.exit, 0);
+        assert_eq!(r.stdout, "HELLO\n");
+    }
+
+    #[test]
+    fn ci_sh_reports_nonzero_exit_without_erroring() {
+        let r = run_sh_via_job(
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push] (fn [_] (ci.sh "exit 7")))"#,
+        );
+        assert_eq!(r.exit, 7);
+    }
+
+    #[test]
+    fn ci_sh_merges_env_into_inherited() {
+        // SAFETY: setting an env var in a single-threaded test process.
+        unsafe {
+            std::env::set_var("CI_SH_INHERITED_TEST", "from-parent");
+        }
+        let r = run_sh_via_job(
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push]
+  (fn [_]
+    (ci.sh "echo $CI_SH_INHERITED_TEST $CI_SH_OVERRIDE_TEST"
+           {:env {:CI_SH_OVERRIDE_TEST "from-opts"}})))"#,
+        );
+        assert_eq!(r.exit, 0);
+        assert_eq!(r.stdout, "from-parent from-opts\n");
+    }
+
+    #[test]
+    fn ci_sh_honors_cwd() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        // Resolve symlinks (macOS /tmp → /private/tmp) so the assertion holds.
+        let canonical = fs_err::canonicalize(dir.path()).expect("canonicalize");
+        let source = format!(
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push] (fn [_] (ci.sh "pwd" {{:cwd "{}"}})))"#,
+            canonical.display()
+        );
+        let r = run_sh_via_job(&source);
+        assert_eq!(r.exit, 0);
+        assert_eq!(r.stdout.trim(), canonical.to_string_lossy());
+    }
+
+    #[test]
+    fn ci_sh_rejects_unknown_opt_key() {
+        let f = fennel();
+        let pipeline = load(
+            &f,
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push] (fn [_] (ci.sh "echo hi" {:cwdir "/tmp"})))"#,
+            "ci.fnl",
+            "ci.fnl",
+            HashMap::new(),
+        )
+        .expect("load should succeed");
+        let err = pipeline.jobs()[0]
+            .run_fn
+            .call::<mlua::Value>(())
+            .unwrap_err();
+        let msg = err.to_string();
+        assert!(
+            msg.contains("unknown field") && msg.contains("cwdir"),
+            "expected unknown-field error mentioning the typo, got: {msg}"
+        );
+    }
+
+    #[test]
+    fn ci_sh_rejects_empty_argv() {
+        let f = fennel();
+        let pipeline = load(
+            &f,
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push] (fn [_] (ci.sh [])))"#,
+            "ci.fnl",
+            "ci.fnl",
+            HashMap::new(),
+        )
+        .expect("load should succeed");
+        let err = pipeline.jobs()[0]
+            .run_fn
+            .call::<mlua::Value>(())
+            .unwrap_err();
+        let msg = err.to_string();
+        assert!(
+            msg.contains("empty"),
+            "expected empty-argv error, got: {msg}"
+        );
+    }
+}
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index 3b5ed1a..9bc0d0e 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -1,5 +1,6 @@
 //! CI: trigger runs from push events, validate the job graph.
 
+mod lua;
 pub mod pipeline;
 pub mod run;
 
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index 72babfa..0626ea9 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -1,12 +1,14 @@
-//! CI job graph: evaluation of `ci.fnl` and validation rules.
+//! CI job graph: validation rules and the `load` entry point that
+//! parses a `ci.fnl` source string into a `Pipeline`.
+//!
+//! Lua/Fennel evaluation lives in the sibling [`super::lua`] module;
+//! this module owns the domain types and the structural rules.
 
-use std::cell::RefCell;
 use std::collections::HashMap;
-use std::rc::Rc;
 
 use miette::{NamedSource, SourceSpan};
-use mlua::{Lua, LuaSerdeExt};
 
+use super::lua;
 use crate::Result;
 use crate::fennel::Fennel;
 use crate::secret::SecretString;
@@ -35,7 +37,10 @@ impl Job {
     /// per-job validation rules. `line` is the 1-indexed source line of
     /// the call site; `source` is the full Fennel source string used to
     /// compute the diagnostic span.
-    fn new(
+    ///
+    /// Visible to the sibling `lua` module which constructs jobs from
+    /// the registration callback.
+    pub(super) fn new(
         id: String,
         inputs: Vec<String>,
         run_fn: mlua::Function,
@@ -77,196 +82,12 @@ impl Pipeline {
     }
 }
 
-/// The `quire.ci` module exposed to Fennel scripts via `require`.
-///
-/// `install` stows the module on the Lua VM via `set_app_data`, then
-/// builds a plain table whose entries are bare functions that look the
-/// module back up at call time. Both `(ci.job …)` field access and
-/// `(local {: job : secret} (require :quire.ci))` destructuring work.
-///
-/// ```fennel
-/// (local ci (require :quire.ci))
-/// (ci.job :build [:quire/push] (fn [_] nil))
-/// (ci.secret :github_token)
-/// ```
-struct CiModule {
-    jobs: Rc<RefCell<Vec<std::result::Result<Job, ValidationError>>>>,
-    source: Rc<String>,
-    secrets: Rc<HashMap<String, SecretString>>,
-}
-
-impl CiModule {
-    /// Install the module on `lua` as app data and return the
-    /// `quire.ci` table. The registered functions error at call time
-    /// if the module isn't installed first.
-    fn install(self, lua: &Lua) -> mlua::Result<mlua::Table> {
-        lua.set_app_data(self);
-        let table = lua.create_table()?;
-        table.set("job", lua.create_function(register_job)?)?;
-        table.set("secret", lua.create_function(lookup_secret)?)?;
-        table.set("sh", lua.create_function(run_sh)?)?;
-        Ok(table)
-    }
-}
-
-/// Pull the `CiModule` off the Lua VM's app data. Errors with a
-/// reasonable message if `install` was never called — should be
-/// impossible in practice but worth surfacing if it ever happens.
-fn module(lua: &Lua) -> mlua::Result<mlua::AppDataRef<'_, CiModule>> {
-    lua.app_data_ref::<CiModule>()
-        .ok_or_else(|| mlua::Error::external("quire.ci module not installed on Lua VM"))
-}
-
-/// Body of `(ci.job id inputs run-fn)`. Captures the call-site line
-/// from the Lua debug stack so per-job validation errors carry a span
-/// pointing back at the user's source.
-fn register_job(
-    lua: &Lua,
-    (id, inputs, run_fn): (String, Vec<String>, mlua::Function),
-) -> mlua::Result<()> {
-    let m = module(lua)?;
-    let line = lua
-        .inspect_stack(1, |d| d.current_line())
-        .flatten()
-        .map(|l| l as u32)
-        .unwrap_or(0);
-    m.jobs
-        .borrow_mut()
-        .push(Job::new(id, inputs, run_fn, line, &m.source));
-    Ok(())
-}
-
-/// Body of `(ci.secret name)`. Errors as a Lua error if the name is
-/// undeclared or the file form fails to read.
-fn lookup_secret(lua: &Lua, name: String) -> mlua::Result<String> {
-    let m = module(lua)?;
-    let secret = m
-        .secrets
-        .get(&name)
-        .ok_or_else(|| mlua::Error::external(crate::Error::UnknownSecret(name)))?;
-    secret
-        .reveal()
-        .map(|s| s.to_string())
-        .map_err(mlua::Error::external)
-}
-
-/// The two valid shapes of `cmd` for `(ci.sh cmd …)`. A bare string
-/// runs under `sh -c`; a sequence runs as argv with no shell.
-#[derive(serde::Deserialize)]
-#[serde(untagged)]
-enum Cmd {
-    Shell(String),
-    Argv(Vec<String>),
-}
-
-impl From<Cmd> for std::process::Command {
-    fn from(cmd: Cmd) -> Self {
-        match cmd {
-            Cmd::Shell(s) => {
-                let mut c = std::process::Command::new("sh");
-                c.arg("-c").arg(s);
-                c
-            }
-            Cmd::Argv(argv) => {
-                let mut c = std::process::Command::new(&argv[0]);
-                c.args(&argv[1..]);
-                c
-            }
-        }
-    }
-}
-
-impl Cmd {
-    /// 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.
-    fn run(self, opts: ShOpts) -> std::io::Result<Output> {
-        let mut command: std::process::Command = self.into();
-        for (k, v) in opts.env {
-            command.env(k, v);
-        }
-        if let Some(cwd) = opts.cwd {
-            command.current_dir(cwd);
-        }
-        let output = command.output()?;
-        Ok(Output {
-            exit: output.status.code().unwrap_or(-1),
-            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
-            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
-        })
-    }
-}
-
-impl mlua::FromLua for Cmd {
-    fn from_lua(value: mlua::Value, lua: &Lua) -> mlua::Result<Self> {
-        // Pre-check the Lua type so wrong-shape inputs get specific
-        // FromLuaConversionError messages — serde's untagged dispatch
-        // would otherwise just say "data did not match any variant".
-        match &value {
-            mlua::Value::String(_) => lua.from_value(value),
-            mlua::Value::Table(t) if t.raw_len() == 0 => {
-                Err(mlua::Error::FromLuaConversionError {
-                    from: "table",
-                    to: "Cmd".into(),
-                    message: Some("ci.sh: argv list is empty".into()),
-                })
-            }
-            mlua::Value::Table(_) => lua.from_value(value),
-            other => Err(mlua::Error::FromLuaConversionError {
-                from: other.type_name(),
-                to: "Cmd".into(),
-                message: Some("ci.sh: cmd must be a string or sequence of strings".into()),
-            }),
-        }
-    }
-}
-
-/// The optional `opts` table for `(ci.sh cmd opts?)`. Unknown keys
-/// fail closed so typos surface rather than being silently ignored.
-#[derive(Default, serde::Deserialize)]
-#[serde(default, deny_unknown_fields)]
-struct ShOpts {
-    env: HashMap<String, String>,
-    cwd: Option<String>,
-}
-
-impl mlua::FromLua for ShOpts {
-    fn from_lua(value: mlua::Value, lua: &Lua) -> mlua::Result<Self> {
-        lua.from_value(value)
-    }
-}
-
-/// The captured outcome of running a process — what `(ci.sh …)`
-/// returns. Crosses the boundary as plain serde data: `lua.to_value`
-/// on the way out, `lua.from_value` on the way in.
-///
-/// A non-zero exit is reported in `:exit`, not raised as a Lua error —
-/// matches the shape `(container …)` will eventually use so callers can
-/// branch on it.
-#[derive(serde::Serialize, serde::Deserialize)]
-struct Output {
-    exit: i32,
-    stdout: String,
-    stderr: String,
-}
-
-/// Body of `(ci.sh cmd opts?)`. Glue between the Lua call and
-/// `Cmd::run` — defaults the opts and converts both directions.
-fn run_sh(lua: &Lua, (cmd, opts): (Cmd, Option<ShOpts>)) -> mlua::Result<mlua::Value> {
-    let output = cmd
-        .run(opts.unwrap_or_default())
-        .map_err(mlua::Error::external)?;
-    lua.to_value(&output)
-}
-
 /// Parse and validate a ci.fnl source string into a `Pipeline`.
 ///
-/// Injects `quire.ci` into `package.loaded` so scripts can
-/// `(require :quire.ci)`, evaluates the source to register jobs, runs
-/// the per-job pre-graph rules during registration, and then runs
-/// the post-graph rules over the surviving jobs. Any errors found are
-/// gathered into a single `LoadError` carrying the source for miette
-/// to render with inline labels.
+/// Delegates evaluation to [`lua::parse`] for the Fennel-side work,
+/// then runs the post-graph rules over the surviving jobs. Any errors
+/// found are gathered into a single `LoadError` carrying the source
+/// for miette to render with inline labels.
 pub(crate) fn load(
     fennel: &Fennel,
     source: &str,
@@ -274,7 +95,7 @@ pub(crate) fn load(
     display: &str,
     secrets: HashMap<String, SecretString>,
 ) -> Result<Pipeline> {
-    let results = parse(fennel, source, filename, display, secrets)?;
+    let results = lua::parse(fennel, source, filename, display, secrets)?;
 
     let mut errors = Vec::new();
     let mut jobs = Vec::new();
@@ -300,36 +121,6 @@ pub(crate) fn load(
     }
 }
 
-/// Evaluate `source` with the `quire.ci` module bound and collect the
-/// registration results — one `Result` per `(ci.job …)` call. Pre-graph
-/// rules run inside the callback, so a single bad job does not abort
-/// the rest of the script.
-fn parse(
-    fennel: &Fennel,
-    source: &str,
-    filename: &str,
-    display: &str,
-    secrets: HashMap<String, SecretString>,
-) -> Result<Vec<std::result::Result<Job, ValidationError>>> {
-    let jobs = Rc::new(RefCell::new(Vec::new()));
-    let src = Rc::new(source.to_string());
-    let secrets = Rc::new(secrets);
-
-    fennel.eval_raw(source, filename, display, |lua| {
-        let module = CiModule {
-            jobs: jobs.clone(),
-            source: src.clone(),
-            secrets: secrets.clone(),
-        }
-        .install(lua)?;
-        let loaded: mlua::Table = lua.globals().get::<mlua::Table>("package")?.get("loaded")?;
-        loaded.set("quire.ci", module)?;
-        Ok(())
-    })?;
-
-    Ok(jobs.take())
-}
-
 /// Compute a span covering the given 1-indexed line in `source`.
 /// Returns an empty span at offset 0 when the line is unknown.
 fn span_for_line(source: &str, line: u32) -> SourceSpan {
@@ -406,7 +197,8 @@ pub struct LoadError {
 /// reachability — over the surviving jobs from parsing.
 ///
 /// Per-job pre-graph rules (slash-in-id, empty inputs) run inside the
-/// `(ci.job …)` callback during `parse`, so they are not re-checked here.
+/// `(ci.job …)` callback during `lua::parse`, so they are not re-checked
+/// here.
 fn validate_post_graph(jobs: &[Job]) -> std::result::Result<(), Vec<ValidationError>> {
     let mut errors = Vec::new();
 
@@ -500,7 +292,8 @@ mod tests {
         let f = fennel();
         let source = r#"(local ci (require :quire.ci))
 (ci.job :test [:quire/push] (fn [_] nil))"#;
-        let pipeline = load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
+        let pipeline =
+            load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
         let jobs = pipeline.jobs();
         assert_eq!(jobs.len(), 1);
         assert_eq!(jobs[0].id, "test");
@@ -515,7 +308,8 @@ mod tests {
 (ci.job :build [:quire/push] (fn [_] nil))
 (ci.job :test [:build] (fn [_] nil))
 "#;
-        let pipeline = load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
+        let pipeline =
+            load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
         let jobs = pipeline.jobs();
         assert_eq!(jobs.len(), 2);
         assert_eq!(jobs[0].id, "build");
@@ -533,7 +327,8 @@ mod tests {
 
 
 (ci.job :sixth [:quire/push] (fn [_] nil))";
-        let pipeline = load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
+        let pipeline =
+            load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
         let lines: Vec<usize> = pipeline
             .jobs()
             .iter()
@@ -554,7 +349,7 @@ mod tests {
     /// `Err(ValidationError)`.
     fn parse_results(source: &str) -> Vec<std::result::Result<Job, ValidationError>> {
         let f = fennel();
-        parse(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("parse should succeed")
+        lua::parse(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("parse should succeed")
     }
 
     /// Discard parse errors and return only the successfully registered
@@ -667,166 +462,6 @@ mod tests {
         );
     }
 
-    #[test]
-    fn ci_secret_returns_resolved_value() {
-        let f = fennel();
-        let mut secrets = HashMap::new();
-        secrets.insert(
-            "github_token".to_string(),
-            SecretString::from_plain("ghp_test_value"),
-        );
-        let source = r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push] (fn [_] (ci.secret :github_token)))"#;
-        let pipeline = load(&f, source, "ci.fnl", "ci.fnl", secrets)
-            .expect("load should succeed");
-        let token: String = pipeline.jobs()[0]
-            .run_fn
-            .call(())
-            .expect("run_fn should return the secret value");
-        assert_eq!(token, "ghp_test_value");
-    }
-
-    #[test]
-    fn ci_secret_errors_for_unknown_name() {
-        let f = fennel();
-        let source = r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push] (fn [_] (ci.secret :missing)))"#;
-        let pipeline = load(&f, source, "ci.fnl", "ci.fnl", HashMap::new())
-            .expect("load should succeed");
-        let err = pipeline.jobs()[0]
-            .run_fn
-            .call::<mlua::Value>(())
-            .unwrap_err();
-        let msg = err.to_string();
-        assert!(
-            msg.contains("unknown secret") && msg.contains("missing"),
-            "expected unknown-secret error mentioning the name, got: {msg}"
-        );
-    }
-
-    /// Build a pipeline whose single job's run-fn invokes `(ci.sh …)`,
-    /// invoke it, and decode the resulting Lua table as Output through
-    /// the same VM via lua.from_value. Owned data, so the Fennel VM can
-    /// drop without a use-after-free.
-    fn run_sh_via_job(source: &str) -> Output {
-        let f = fennel();
-        let pipeline =
-            load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
-        let value: mlua::Value = pipeline.jobs()[0]
-            .run_fn
-            .call(())
-            .expect("ci.sh call should return a value");
-        f.lua().from_value(value).expect("decode Output")
-    }
-
-    #[test]
-    fn ci_sh_runs_argv_and_captures_stdout() {
-        let r = run_sh_via_job(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh ["echo" "hello"])))"#,
-        );
-        assert_eq!(r.exit, 0);
-        assert_eq!(r.stdout, "hello\n");
-        assert!(r.stderr.is_empty());
-    }
-
-    #[test]
-    fn ci_sh_runs_string_under_shell() {
-        let r = run_sh_via_job(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh "echo hello | tr a-z A-Z")))"#,
-        );
-        assert_eq!(r.exit, 0);
-        assert_eq!(r.stdout, "HELLO\n");
-    }
-
-    #[test]
-    fn ci_sh_reports_nonzero_exit_without_erroring() {
-        let r = run_sh_via_job(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh "exit 7")))"#,
-        );
-        assert_eq!(r.exit, 7);
-    }
-
-    #[test]
-    fn ci_sh_merges_env_into_inherited() {
-        // SAFETY: setting an env var in a single-threaded test process.
-        unsafe {
-            std::env::set_var("CI_SH_INHERITED_TEST", "from-parent");
-        }
-        let r = run_sh_via_job(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push]
-  (fn [_]
-    (ci.sh "echo $CI_SH_INHERITED_TEST $CI_SH_OVERRIDE_TEST"
-           {:env {:CI_SH_OVERRIDE_TEST "from-opts"}})))"#,
-        );
-        assert_eq!(r.exit, 0);
-        assert_eq!(r.stdout, "from-parent from-opts\n");
-    }
-
-    #[test]
-    fn ci_sh_honors_cwd() {
-        let dir = tempfile::tempdir().expect("tempdir");
-        // Resolve symlinks (macOS /tmp → /private/tmp) so the assertion holds.
-        let canonical = fs_err::canonicalize(dir.path()).expect("canonicalize");
-        let source = format!(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh "pwd" {{:cwd "{}"}})))"#,
-            canonical.display()
-        );
-        let r = run_sh_via_job(&source);
-        assert_eq!(r.exit, 0);
-        assert_eq!(r.stdout.trim(), canonical.to_string_lossy());
-    }
-
-    #[test]
-    fn ci_sh_rejects_unknown_opt_key() {
-        let f = fennel();
-        let pipeline = load(
-            &f,
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh "echo hi" {:cwdir "/tmp"})))"#,
-            "ci.fnl",
-            "ci.fnl",
-            HashMap::new(),
-        )
-        .expect("load should succeed");
-        let err = pipeline.jobs()[0]
-            .run_fn
-            .call::<mlua::Value>(())
-            .unwrap_err();
-        let msg = err.to_string();
-        assert!(
-            msg.contains("unknown field") && msg.contains("cwdir"),
-            "expected unknown-field error mentioning the typo, got: {msg}"
-        );
-    }
-
-    #[test]
-    fn ci_sh_rejects_empty_argv() {
-        let f = fennel();
-        let pipeline = load(
-            &f,
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh [])))"#,
-            "ci.fnl",
-            "ci.fnl",
-            HashMap::new(),
-        )
-        .expect("load should succeed");
-        let err = pipeline.jobs()[0]
-            .run_fn
-            .call::<mlua::Value>(())
-            .unwrap_err();
-        let msg = err.to_string();
-        assert!(
-            msg.contains("empty"),
-            "expected empty-argv error, got: {msg}"
-        );
-    }
-
     #[test]
     fn validate_rejects_unreachable_jobs() {
         // An orphan with non-empty self-input passes pre-graph rules