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