Split lua.rs into registration and runtime sibling modules
The lua namespace conflated two phases (DSL setup and per-execution
state) and forced cross-namespace visibility annotations for sibling
features like mirror. Flat siblings let pub(super) reach across all
ci modules without escalating to pub(crate).

Assisted-by: Claude Opus 4.7 via Claude Code
change yvyklkzqwsmyuzorpoosxzoomuqnpvku
commit 8ca7fc0170d36a16c3a5af2a9605f091a07c4859
author Alpha Chen <alpha@kejadlen.dev>
date
parent ypsnktxl
diff --git a/src/ci/mirror.rs b/src/ci/mirror.rs
index 3e92e61..dd3088d 100644
--- a/src/ci/mirror.rs
+++ b/src/ci/mirror.rs
@@ -12,8 +12,9 @@ use std::rc::Rc;
 
 use mlua::{Lua, LuaSerdeExt};
 
-use super::lua::{Cmd, Registration, Runtime, ShOpts, ShOutput};
 use super::pipeline::{self, DefinitionError, Job, RunFn};
+use super::registration::Registration;
+use super::runtime::{Cmd, Runtime, ShOpts, ShOutput};
 use crate::Result;
 use crate::error::Error;
 
@@ -273,9 +274,9 @@ mod tests {
     use super::*;
     use mlua::IntoLua;
 
-    use crate::ci::lua::RuntimeHandle;
     use crate::ci::pipeline::{Diagnostic, RustRunFn, compile};
     use crate::ci::run::RunMeta;
+    use crate::ci::runtime::RuntimeHandle;
     use crate::secret::SecretString;
 
     /// Set up a bare git repo with one commit. Returns the tempdir,
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index 8ab426d..0916015 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -2,10 +2,11 @@
 
 use std::collections::HashMap;
 
-mod lua;
 mod mirror;
 mod pipeline;
+mod registration;
 mod run;
+mod runtime;
 
 pub use pipeline::{DefinitionError, Diagnostic, Job, Pipeline, PipelineError, StructureError};
 pub use run::{Run, RunMeta, RunState, RunTimes, Runs};
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index bad931e..1a6f6ed 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -1,8 +1,8 @@
 //! CI job graph: validation rules and the [`compile`] entry point
 //! that turns 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.
+//! Lua/Fennel evaluation lives in the sibling [`super::registration`]
+//! module; this module owns the domain types and the structural rules.
 
 use std::collections::{HashMap, HashSet};
 
@@ -11,7 +11,7 @@ use petgraph::Graph;
 use petgraph::graph::NodeIndex;
 use petgraph::visit::{Bfs, Reversed};
 
-use super::lua::{self, Registrations};
+use super::registration::{self, Registrations};
 use crate::Result;
 use crate::fennel::Fennel;
 
@@ -111,7 +111,7 @@ pub struct Job {
 
 /// 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<()>>;
+pub(super) type RustRunFn = std::rc::Rc<dyn Fn(&super::runtime::Runtime) -> Result<()>>;
 
 /// How a job runs at execute time.
 ///
@@ -152,8 +152,8 @@ impl Job {
     /// legitimately register jobs at `quire/<name>` and skip that
     /// rule.
     ///
-    /// Visible to the sibling `lua` module which constructs jobs from
-    /// the registration callbacks.
+    /// Visible to the sibling `registration` module which constructs
+    /// jobs from the registration callbacks.
     pub(super) fn new(
         id: String,
         inputs: Vec<String>,
@@ -337,14 +337,14 @@ pub struct PipelineError {
 
 /// Compile a ci.fnl source string into a validated [`Pipeline`].
 ///
-/// Two phases, fail-fast between them: [`lua::register`] evaluates
-/// the script and reports any definition-time errors, then
+/// Two phases, fail-fast between them: [`registration::register`]
+/// evaluates the script and reports any definition-time errors, then
 /// [`validate_post_graph`] checks the dependency graph. Errors from a
 /// phase are wrapped in a [`PipelineError`] for miette to render with
 /// inline labels.
 pub(crate) fn compile(source: &str, name: &str) -> Result<Pipeline> {
     let fennel = Fennel::new()?;
-    let Registrations { jobs, image } = lua::register(&fennel, source, name)?;
+    let Registrations { jobs, image } = registration::register(&fennel, source, name)?;
 
     let (graph, node_index) = build_graph(&jobs);
 
@@ -369,8 +369,8 @@ pub(crate) fn compile(source: &str, name: &str) -> Result<Pipeline> {
 /// reachability — over the surviving jobs from registration.
 ///
 /// Per-job pre-graph rules (slash-in-id, empty inputs) run inside the
-/// `(ci.job …)` callback during `lua::register`, so they are not
-/// re-checked here.
+/// `(ci.job …)` callback during `registration::register`, so they are
+/// not re-checked here.
 fn validate_post_graph(
     jobs: &[Job],
     graph: &JobGraph,
@@ -503,7 +503,7 @@ mod tests {
     /// their non-VM fields here.
     fn registered_jobs(source: &str) -> Vec<Job> {
         let f = Fennel::new().expect("Fennel::new() should succeed");
-        lua::register(&f, source, "ci.fnl")
+        registration::register(&f, source, "ci.fnl")
             .expect("register should succeed")
             .jobs
     }
@@ -512,7 +512,7 @@ mod tests {
     /// definition errors it produced.
     fn registration_errors(source: &str) -> Vec<DefinitionError> {
         let f = Fennel::new().expect("Fennel::new() should succeed");
-        let err = lua::register(&f, source, "ci.fnl").expect_err("expected registration errors");
+        let err = registration::register(&f, source, "ci.fnl").expect_err("expected registration errors");
         let crate::Error::Pipeline(pe) = err else {
             panic!("expected PipelineError, got {err:?}")
         };
diff --git a/src/ci/registration.rs b/src/ci/registration.rs
new file mode 100644
index 0000000..9a0a947
--- /dev/null
+++ b/src/ci/registration.rs
@@ -0,0 +1,177 @@
+//! Registration-time DSL: evaluating a `ci.fnl` script with the
+//! `(require :quire.ci)` module bound and collecting the jobs and
+//! image it registers.
+//!
+//! The pipeline module calls [`register`] to drive evaluation; the
+//! runtime module is not involved here. Per-job validation errors
+//! collected during evaluation are returned as a single
+//! `PipelineError`, not raised as Lua errors.
+
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use mlua::{IntoLua, Lua};
+
+use miette::NamedSource;
+
+use super::mirror;
+use super::pipeline::{self, DefinitionError, Diagnostic, Job, PipelineError, RunFn};
+use crate::Result;
+use crate::fennel::Fennel;
+
+/// Output of [`register`]: jobs and image successfully registered
+/// from the script. Definition-time errors are returned via the `Err`
+/// arm, not collected here.
+#[derive(Debug)]
+pub(super) struct Registrations {
+    pub(super) jobs: Vec<Job>,
+    pub(super) image: Option<String>,
+}
+
+/// Evaluate `source` with the registration module bound and collect
+/// what got registered.
+///
+/// Pre-graph rules run inside the callback, so a single bad job does
+/// not abort the rest of the script — but if any rule fired, the
+/// whole batch is returned as a `PipelineError` instead of partial
+/// registrations.
+pub(super) fn register(fennel: &Fennel, source: &str, name: &str) -> Result<Registrations> {
+    let jobs: Rc<RefCell<Vec<Job>>> = Rc::new(RefCell::new(Vec::new()));
+    let image = Rc::new(RefCell::new(None));
+    let mirror_cell = Rc::new(RefCell::new(None));
+    let src = Rc::new(source.to_string());
+
+    let errors = Rc::new(RefCell::new(Vec::new()));
+
+    fennel.eval_raw(source, name, |lua| {
+        lua.register_module(
+            "quire.ci",
+            Registration {
+                jobs: jobs.clone(),
+                errors: errors.clone(),
+                image: image.clone(),
+                mirror: mirror_cell.clone(),
+                source: src.clone(),
+            },
+        )
+    })?;
+
+    // Remove the Registration app data so `ci.image`/`ci.job` calls at
+    // runtime (inside run-fns) hit "registration not installed" instead of
+    // silently pushing into the already-consumed sinks.
+    fennel.lua().remove_app_data::<Registration>();
+
+    let errors = errors.take();
+    if !errors.is_empty() {
+        return Err(PipelineError {
+            src: NamedSource::new(name, source.to_string()),
+            diagnostics: errors.into_iter().map(Diagnostic::Definition).collect(),
+        }
+        .into());
+    }
+
+    let image_name = image.borrow().as_ref().map(|i| i.name.clone());
+    Ok(Registrations {
+        jobs: jobs.take(),
+        image: image_name,
+    })
+}
+
+/// The registration-time module exposed to Fennel scripts via
+/// `(require :quire.ci)`.
+///
+/// Converted into a Lua table via [`IntoLua`]: stows itself on the
+/// VM as app data (so `register_job` can find the registration sink)
+/// and returns a table whose only entry is `job`. Runtime primitives
+/// (`sh`, `secret`) live on the per-execution `Runtime` handle, not
+/// here.
+///
+/// ```fennel
+/// (local ci (require :quire.ci))
+/// (ci.job :build [:quire/push]
+///   (fn [{: sh : secret}]
+///     (sh ["echo" (secret :github_token)])))
+/// ```
+pub(super) struct Registration {
+    pub(super) jobs: Rc<RefCell<Vec<Job>>>,
+    pub(super) errors: Rc<RefCell<Vec<DefinitionError>>>,
+    image: Rc<RefCell<Option<ImageRegistration>>>,
+    pub(super) mirror: Rc<RefCell<Option<mirror::MirrorRegistration>>>,
+    pub(super) source: Rc<String>,
+}
+
+impl IntoLua for Registration {
+    fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
+        lua.set_app_data(self);
+        let table = lua.create_table()?;
+        table.set("job", lua.create_function(register_job)?)?;
+        table.set("image", lua.create_function(register_image)?)?;
+        table.set("mirror", lua.create_function(mirror::register_mirror)?)?;
+        table.into_lua(lua)
+    }
+}
+
+/// A pending image registration extracted from the Lua callback.
+struct ImageRegistration {
+    name: String,
+    _line: u32,
+}
+
+/// Body of `(ci.image name)`. Records the image on the first call;
+/// pushes a `DuplicateImage` error on subsequent calls.
+fn register_image(lua: &Lua, (name,): (String,)) -> mlua::Result<()> {
+    let r = lua
+        .app_data_ref::<Registration>()
+        .ok_or_else(|| mlua::Error::external("quire.ci registration not installed on Lua VM"))?;
+    let line = lua
+        .inspect_stack(1, |d| d.current_line())
+        .flatten()
+        .map(|l| l as u32)
+        .unwrap_or(0);
+    let mut img = r.image.borrow_mut();
+    match &*img {
+        Some(_) => {
+            let span = pipeline::span_for_line(&r.source, line);
+            r.errors
+                .borrow_mut()
+                .push(DefinitionError::DuplicateImage { span });
+        }
+        None => {
+            *img = Some(ImageRegistration { name, _line: line });
+        }
+    }
+    Ok(())
+}
+
+/// 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. Enforces the user-facing
+/// reserved-slash rule: ids may not contain `/`, since the `quire/`
+/// namespace is reserved for built-in helpers (see `mirror::register_mirror`).
+fn register_job(
+    lua: &Lua,
+    (id, inputs, run_fn): (String, Vec<String>, mlua::Function),
+) -> mlua::Result<()> {
+    let r = lua
+        .app_data_ref::<Registration>()
+        .ok_or_else(|| mlua::Error::external("quire.ci registration not installed on Lua VM"))?;
+    let line = lua
+        .inspect_stack(1, |d| d.current_line())
+        .flatten()
+        .map(|l| l as u32)
+        .unwrap_or(0);
+
+    if id.contains('/') {
+        let span = pipeline::span_for_line(&r.source, line);
+        r.errors
+            .borrow_mut()
+            .push(DefinitionError::ReservedSlash { job_id: id, span });
+        return Ok(());
+    }
+
+    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),
+    }
+    Ok(())
+}
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 87fb0c5..4222666 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -12,7 +12,7 @@ use std::rc::Rc;
 use jiff::Timestamp;
 use mlua::IntoLua;
 
-use super::lua::{Runtime, RuntimeHandle, ShOutput};
+use super::runtime::{Runtime, RuntimeHandle, ShOutput};
 use super::pipeline::{Pipeline, RunFn};
 use crate::display_chain;
 use crate::secret::SecretString;
diff --git a/src/ci/lua.rs b/src/ci/runtime.rs
similarity index 77%
rename from src/ci/lua.rs
rename to src/ci/runtime.rs
index 457ca40..9f97687 100644
--- a/src/ci/lua.rs
+++ b/src/ci/runtime.rs
@@ -1,11 +1,10 @@
-//! Lua bridge for `ci.fnl`: the registration-time module exposed via
-//! `(require :quire.ci)` and the per-execution runtime handle passed
-//! into each job's `run-fn`.
+//! Per-execution runtime: the state passed to each job's `run-fn`.
 //!
-//! All mlua/Fennel interaction lives here. The pipeline module calls
-//! [`register`] to evaluate a script and collect the registered jobs;
-//! the run module installs a [`Runtime`] and threads its handle into
-//! each `run-fn` at execute time.
+//! Owns the Lua VM (via the consumed `Pipeline`), the secrets the job
+//! may resolve, the per-job `(jobs name)` views, the current-job
+//! cursor, and the captured `sh` outputs. The handle threaded into
+//! each `run-fn` exposes three primitives — `sh`, `secret`, `jobs` —
+//! implemented by the free functions below.
 
 use std::cell::RefCell;
 use std::collections::HashMap;
@@ -13,170 +12,10 @@ use std::rc::Rc;
 
 use mlua::{IntoLua, Lua, LuaSerdeExt};
 
-use miette::NamedSource;
-
-use super::pipeline::{DefinitionError, Diagnostic, Job, PipelineError, RunFn};
-use crate::Result;
-use crate::fennel::Fennel;
+use super::pipeline::{Job, Pipeline};
+use super::run::RunMeta;
 use crate::secret::SecretString;
 
-/// Output of [`register`]: jobs and image successfully registered
-/// from the script. Definition-time errors are returned via the `Err`
-/// arm, not collected here.
-#[derive(Debug)]
-pub(super) struct Registrations {
-    pub(super) jobs: Vec<Job>,
-    pub(super) image: Option<String>,
-}
-
-/// Evaluate `source` with the registration module bound and collect
-/// what got registered.
-///
-/// Pre-graph rules run inside the callback, so a single bad job does
-/// not abort the rest of the script — but if any rule fired, the
-/// whole batch is returned as a `PipelineError` instead of partial
-/// registrations.
-pub(super) fn register(fennel: &Fennel, source: &str, name: &str) -> Result<Registrations> {
-    let jobs: Rc<RefCell<Vec<Job>>> = Rc::new(RefCell::new(Vec::new()));
-    let image = Rc::new(RefCell::new(None));
-    let mirror = Rc::new(RefCell::new(None));
-    let src = Rc::new(source.to_string());
-
-    let errors = Rc::new(RefCell::new(Vec::new()));
-
-    fennel.eval_raw(source, name, |lua| {
-        lua.register_module(
-            "quire.ci",
-            Registration {
-                jobs: jobs.clone(),
-                errors: errors.clone(),
-                image: image.clone(),
-                mirror: mirror.clone(),
-                source: src.clone(),
-            },
-        )
-    })?;
-
-    // Remove the Registration app data so `ci.image`/`ci.job` calls at
-    // runtime (inside run-fns) hit "registration not installed" instead of
-    // silently pushing into the already-consumed sinks.
-    fennel.lua().remove_app_data::<Registration>();
-
-    let errors = errors.take();
-    if !errors.is_empty() {
-        return Err(PipelineError {
-            src: NamedSource::new(name, source.to_string()),
-            diagnostics: errors.into_iter().map(Diagnostic::Definition).collect(),
-        }
-        .into());
-    }
-
-    let image_name = image.borrow().as_ref().map(|i| i.name.clone());
-    Ok(Registrations {
-        jobs: jobs.take(),
-        image: image_name,
-    })
-}
-
-/// The registration-time module exposed to Fennel scripts via
-/// `(require :quire.ci)`.
-///
-/// Converted into a Lua table via [`IntoLua`]: stows itself on the
-/// VM as app data (so `register_job` can find the registration sink)
-/// and returns a table whose only entry is `job`. Runtime primitives
-/// (`sh`, `secret`) live on the per-execution [`Runtime`] handle, not
-/// here.
-///
-/// ```fennel
-/// (local ci (require :quire.ci))
-/// (ci.job :build [:quire/push]
-///   (fn [{: sh : secret}]
-///     (sh ["echo" (secret :github_token)])))
-/// ```
-pub(super) struct Registration {
-    pub(super) jobs: Rc<RefCell<Vec<Job>>>,
-    pub(super) errors: Rc<RefCell<Vec<DefinitionError>>>,
-    image: Rc<RefCell<Option<ImageRegistration>>>,
-    pub(super) mirror: Rc<RefCell<Option<super::mirror::MirrorRegistration>>>,
-    pub(super) source: Rc<String>,
-}
-
-impl IntoLua for Registration {
-    fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
-        lua.set_app_data(self);
-        let table = lua.create_table()?;
-        table.set("job", lua.create_function(register_job)?)?;
-        table.set("image", lua.create_function(register_image)?)?;
-        table.set("mirror", lua.create_function(super::mirror::register_mirror)?)?;
-        table.into_lua(lua)
-    }
-}
-
-/// A pending image registration extracted from the Lua callback.
-struct ImageRegistration {
-    name: String,
-    _line: u32,
-}
-
-/// Body of `(ci.image name)`. Records the image on the first call;
-/// pushes a `DuplicateImage` error on subsequent calls.
-fn register_image(lua: &Lua, (name,): (String,)) -> mlua::Result<()> {
-    let r = lua
-        .app_data_ref::<Registration>()
-        .ok_or_else(|| mlua::Error::external("quire.ci registration not installed on Lua VM"))?;
-    let line = lua
-        .inspect_stack(1, |d| d.current_line())
-        .flatten()
-        .map(|l| l as u32)
-        .unwrap_or(0);
-    let mut img = r.image.borrow_mut();
-    match &*img {
-        Some(_) => {
-            let span = super::pipeline::span_for_line(&r.source, line);
-            r.errors
-                .borrow_mut()
-                .push(DefinitionError::DuplicateImage { span });
-        }
-        None => {
-            *img = Some(ImageRegistration { name, _line: line });
-        }
-    }
-    Ok(())
-}
-
-/// 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. Enforces the user-facing
-/// reserved-slash rule: ids may not contain `/`, since the `quire/`
-/// namespace is reserved for built-in helpers (see `register_mirror`).
-fn register_job(
-    lua: &Lua,
-    (id, inputs, run_fn): (String, Vec<String>, mlua::Function),
-) -> mlua::Result<()> {
-    let r = lua
-        .app_data_ref::<Registration>()
-        .ok_or_else(|| mlua::Error::external("quire.ci registration not installed on Lua VM"))?;
-    let line = lua
-        .inspect_stack(1, |d| d.current_line())
-        .flatten()
-        .map(|l| l as u32)
-        .unwrap_or(0);
-
-    if id.contains('/') {
-        let span = super::pipeline::span_for_line(&r.source, line);
-        r.errors
-            .borrow_mut()
-            .push(DefinitionError::ReservedSlash { job_id: id, span });
-        return Ok(());
-    }
-
-    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),
-    }
-    Ok(())
-}
-
 /// Per-execution runtime: owns the Lua VM, holds the secrets exposed
 /// to the job, the per-job `(jobs name)` views, the current-job
 /// cursor, and the per-job captured `sh` outputs.
@@ -200,7 +39,7 @@ fn register_job(
 /// `(secret …)` and `(jobs …)` require a runtime — without one, calls
 /// error.
 pub(super) struct Runtime {
-    pipeline: super::pipeline::Pipeline,
+    pipeline: Pipeline,
     pub(super) secrets: HashMap<String, SecretString>,
     pub(super) inputs: HashMap<String, HashMap<String, Option<mlua::Value>>>,
     pub(super) current_job: RefCell<Option<String>>,
@@ -220,9 +59,9 @@ impl Runtime {
     /// `RuntimeHandle::into_lua` call at the executor's call site uses
     /// the same boundary.
     pub(super) fn new(
-        pipeline: super::pipeline::Pipeline,
+        pipeline: Pipeline,
         secrets: HashMap<String, SecretString>,
-        meta: &super::run::RunMeta,
+        meta: &RunMeta,
         git_dir: &std::path::Path,
     ) -> Self {
         let transitive = pipeline.transitive_inputs();
@@ -276,7 +115,7 @@ impl Runtime {
     }
 
     /// Look up a job by id.
-    pub(super) fn job(&self, id: &str) -> Option<&super::pipeline::Job> {
+    pub(super) fn job(&self, id: &str) -> Option<&Job> {
         self.pipeline.job(id)
     }
 
@@ -311,10 +150,7 @@ impl Runtime {
 impl Runtime {
     /// Minimal constructor for tests — no source outputs, just
     /// secrets and the pipeline's VM.
-    fn for_test(
-        pipeline: super::pipeline::Pipeline,
-        secrets: HashMap<String, SecretString>,
-    ) -> Self {
+    fn for_test(pipeline: Pipeline, secrets: HashMap<String, SecretString>) -> Self {
         Self {
             pipeline,
             secrets,
@@ -569,14 +405,14 @@ fn run_sh(lua: &Lua, (cmd, opts): (Cmd, Option<ShOpts>)) -> mlua::Result<mlua::V
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::ci::pipeline::{RunFn, compile};
 
     /// Consume the pipeline for its VM, build a minimal runtime,
     /// 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 pipeline = compile(source, "ci.fnl").expect("compile should succeed");
         let run_fn = match pipeline.jobs()[0].run_fn.clone() {
             RunFn::Lua(f) => f,
             RunFn::Rust(_) => panic!("expected RunFn::Lua for test setup"),
@@ -768,5 +604,4 @@ mod tests {
             "expected type error, got: {msg}"
         );
     }
-
 }