Rename and split CI loading types to match actual code structure
Split the monolithic ValidationError into DefinitionError
(registration-time: ReservedSlash, EmptyInputs, DuplicateImage)
and StructureError (post-graph: Cycle, Unreachable). Wrap both
in a Diagnostic enum for miette rendering. Rename LoadError to
PipelineError, Pipeline::load to compile, Ci::load to pipeline,
lua::parse to register, and ParseOutput to Registrations.
The verb chain now reads Ci::pipeline → pipeline::compile →
lua::register, where each verb describes its own layer. No
behavior changes.
Assisted-by: GLM-5.1 via pi
diff --git a/docs/plans/2026-05-02-ci-loading-rename-design.md b/docs/plans/2026-05-02-ci-loading-rename-design.md
new file mode 100644
index 0000000..f16a014
--- /dev/null
+++ b/docs/plans/2026-05-02-ci-loading-rename-design.md
@@ -0,0 +1,94 @@
+# CI loading: rename and re-shape errors
+
+**Goal:** Rationalize the names and error categories around CI loading so the structure matches how the code actually thinks. No behavior changes.
+
+## What's wrong now
+
+Reading `src/ci/{mod,pipeline,lua}.rs` and `src/error.rs` back, three things feel arbitrary:
+
+1. **"load" is overloaded.** `Ci::load`, `Pipeline::load`, and `LoadError` all use the word but mean different things. The overloading is triggered by the error name — once that's gone, the methods read fine.
+2. **`ValidationError` is a grab bag.** Variants come from two distinct stages — registration-time rules (`ReservedSlash`, `EmptyInputs`, `DuplicateImage`) and post-graph rules (`Cycle`, `Unreachable`) — but share one enum, one suffix, and no structure to signal the difference.
+3. **`Error::Validation` names a stage, not a domain.** Compare with the sibling `Error::Fennel`, which names its source. "Validation failed" reads weirdly next to "fennel error."
+
+## New shape
+
+### Two error categories
+
+The split that already exists in the code (per the comment in `validate_post_graph`) is "caught at registration time" vs. "caught after the graph is built." Make that explicit:
+
+```rust
+pub enum DefinitionError {
+ ReservedSlash { job_id: String, span: SourceSpan },
+ EmptyInputs { job_id: String, span: SourceSpan },
+ DuplicateImage { span: SourceSpan },
+}
+
+pub enum StructureError {
+ Cycle { cycle_jobs: Vec<String>, spans: Vec<SourceSpan> },
+ Unreachable { job_id: String, span: SourceSpan },
+}
+
+pub enum Diagnostic {
+ Definition(DefinitionError),
+ Structure(StructureError),
+}
+```
+
+The per-job vs. pipeline-singleton distinction (DuplicateImage is the lone singleton today) is an implementation detail of detection, not a user-visible category, so two buckets is the right count.
+
+### Error bag and top-level variant
+
+```rust
+pub struct PipelineError {
+ pub src: NamedSource<String>,
+ pub diagnostics: Vec<Diagnostic>,
+}
+```
+
+At the top level: `Error::Pipeline(Box<PipelineError>)`. Replaces `Error::Validation(Box<LoadError>)`.
+
+### Method and module renames
+
+| Old | New | Reason |
+|---|---|---|
+| `Ci::load(commit)` | `Ci::pipeline(commit)` | Returns the pipeline at a commit; noun-style accessor reads more naturally than "load". |
+| `Pipeline::load(src, name)` (method) | `pipeline::compile(src, name)` (free fn) | Source → runnable artifact is compilation. Free fn avoids "the type compiles itself." |
+| `lua::parse(...)` | `lua::register(...)` | Phase naming. Fennel's actual parser runs inside `eval_raw`; this function performs the registration phase. |
+| `ParseOutput` | `Registrations` | Names what came out, not what step ran. |
+| `ValidationError` | split into `DefinitionError` + `StructureError` (see above) | Stage-aligned. |
+| `LoadError` | `PipelineError` | Domain-named bag. |
+| `Error::Validation` | `Error::Pipeline` | Domain-named, matches the bag. |
+
+The verb chain reads top-down:
+
+```
+Ci::pipeline → pipeline::compile → lua::register
+```
+
+Each verb describes its own layer. "Register" is imperfect (the Fennel script does the registering; the Rust function provides the sinks and harvests), but it names the phase clearly and avoids "evaluate" — which would collide with Lua's eval terminology.
+
+## Scope
+
+In-scope:
+
+- Renames listed above
+- Splitting the validation enum into two
+- Adding the `Diagnostic` wrapper for miette's `#[related]` iteration
+- Updating tests in `src/ci/pipeline.rs`, `src/ci/lua.rs`, `src/ci/mod.rs`, and `src/error.rs`
+- Updating call sites — at minimum `src/bin/quire/commands/ci.rs`
+
+Out of scope:
+
+- Behavioral changes to validation rules
+- Merging `Error::Fennel` and `Error::Pipeline` (the user-facing "your ci.fnl is bad" framing wasn't a concern in this pass)
+- Restructuring `lua.rs` runtime types (`Runtime`, `RuntimeHandle`, `ShOutput`)
+
+## Verification
+
+- `cargo test` passes with no behavior changes
+- Error rendering for a multi-error ci.fnl still produces the same miette output (modulo the type names in `Debug`)
+- No public API used outside `src/ci/` and `src/bin/` should change names
+
+## Open questions
+
+- "Register" is acceptable but not loved. If a better verb surfaces during implementation, revisit before committing.
diff --git a/src/bin/quire/commands/ci.rs b/src/bin/quire/commands/ci.rs
index 172b3cc..8f7ee97 100644
--- a/src/bin/quire/commands/ci.rs
+++ b/src/bin/quire/commands/ci.rs
@@ -6,7 +6,7 @@ use quire::ci::{Ci, CommitRef, RunMeta, Runs};
/// Validate a repo's ci.fnl without executing any jobs.
///
-/// `Ci::load` parses the Fennel source and validates the resulting
+/// `Ci::pipeline` parses the Fennel source and validates the resulting
/// job graph; this command surfaces the registered jobs and any
/// validation errors via the standard miette diagnostic path.
pub async fn validate(maybe_sha: Option<&str>) -> Result<()> {
@@ -14,7 +14,7 @@ pub async fn validate(maybe_sha: Option<&str>) -> Result<()> {
let commit = resolve_commit(maybe_sha)?;
let ci = Ci::new(repo_path);
- let Some(pipeline) = ci.load(&commit)? else {
+ let Some(pipeline) = ci.pipeline(&commit)? else {
println!("No ci.fnl found at {}.", commit.display);
return Ok(());
};
@@ -48,7 +48,7 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
// Pull secrets from the global config; absence is fine for local
// testing. A broken-but-present config is a real error. Secrets
- // are passed to `Run::execute` rather than `Ci::load` since they
+ // are passed to `Run::execute` rather than `Ci::pipeline` since they
// only matter when the run-fns actually fire.
let secrets = match quire.global_config() {
Ok(c) => c.secrets,
@@ -56,7 +56,7 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
Err(e) => return Err(e).into_diagnostic(),
};
- let Some(pipeline) = ci.load(&commit)? else {
+ let Some(pipeline) = ci.pipeline(&commit)? else {
println!("No ci.fnl found at {}.", commit.display);
return Ok(());
};
diff --git a/src/ci/lua.rs b/src/ci/lua.rs
index c1ea6f9..19686eb 100644
--- a/src/ci/lua.rs
+++ b/src/ci/lua.rs
@@ -3,7 +3,7 @@
//! into each job's `run-fn`.
//!
//! All mlua/Fennel interaction lives here. The pipeline module calls
-//! [`parse`] to evaluate a script and collect the registered jobs;
+//! [`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.
@@ -13,24 +13,24 @@ use std::rc::Rc;
use mlua::{IntoLua, Lua, LuaSerdeExt};
-use super::pipeline::{Job, ValidationError};
+use super::pipeline::{DefinitionError, Job};
use crate::Result;
use crate::fennel::Fennel;
use crate::secret::SecretString;
-/// Output of [`parse`]: registered jobs and the pipeline image (if
-/// declared).
-pub(super) struct ParseOutput {
+/// Output of [`register`]: registered jobs, definition-time errors,
+/// and the pipeline image (if declared).
+pub(super) struct Registrations {
pub(super) jobs: Vec<Job>,
- pub(super) errors: Vec<ValidationError>,
+ pub(super) errors: Vec<DefinitionError>,
pub(super) image: Option<String>,
}
/// Evaluate `source` with the registration module bound and collect
-/// the registration results — one `Result` per `(ci.job …)` call.
+/// the registered jobs, image, and any definition-time errors.
/// 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, name: &str) -> Result<ParseOutput> {
+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 src = Rc::new(source.to_string());
@@ -55,7 +55,7 @@ pub(super) fn parse(fennel: &Fennel, source: &str, name: &str) -> Result<ParseOu
fennel.lua().remove_app_data::<Registration>();
let image_name = image.borrow().as_ref().map(|i| i.name.clone());
- Ok(ParseOutput {
+ Ok(Registrations {
jobs: jobs.take(),
errors: errors.take(),
image: image_name,
@@ -79,7 +79,7 @@ pub(super) fn parse(fennel: &Fennel, source: &str, name: &str) -> Result<ParseOu
/// ```
struct Registration {
jobs: Rc<RefCell<Vec<Job>>>,
- errors: Rc<RefCell<Vec<ValidationError>>>,
+ errors: Rc<RefCell<Vec<DefinitionError>>>,
image: Rc<RefCell<Option<ImageRegistration>>>,
source: Rc<String>,
}
@@ -117,7 +117,7 @@ fn register_image(lua: &Lua, (name,): (String,)) -> mlua::Result<()> {
let span = super::pipeline::span_for_line(&r.source, line);
r.errors
.borrow_mut()
- .push(ValidationError::DuplicateImage { span });
+ .push(DefinitionError::DuplicateImage { span });
}
None => {
*img = Some(ImageRegistration { name, _line: line });
@@ -539,13 +539,13 @@ fn run_sh(lua: &Lua, (cmd, opts): (Cmd, Option<ShOpts>)) -> mlua::Result<mlua::V
#[cfg(test)]
mod tests {
- use super::super::pipeline::Pipeline;
use super::*;
/// Consume the pipeline for its VM, build a minimal runtime,
/// and return the runtime and first job's run_fn.
fn rt(source: &str, secrets: HashMap<String, SecretString>) -> (Rc<Runtime>, mlua::Function) {
- let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+ let pipeline =
+ super::super::pipeline::compile(source, "ci.fnl").expect("compile should succeed");
let run_fn = pipeline.jobs()[0].run_fn.clone();
let runtime = Rc::new(Runtime::for_test(pipeline, secrets));
let _ = RuntimeHandle(runtime.clone())
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index 6faa8bc..59216e2 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -6,7 +6,7 @@ mod lua;
mod pipeline;
mod run;
-pub use pipeline::{Job, LoadError, Pipeline, ValidationError};
+pub use pipeline::{DefinitionError, Diagnostic, Job, Pipeline, PipelineError, StructureError};
pub use run::{Run, RunMeta, RunState, RunTimes, Runs};
/// A resolved commit reference.
@@ -31,9 +31,9 @@ pub const CI_FNL: &str = ".quire/ci.fnl";
/// Access to CI operations for a single repo.
///
-/// Provides load and validation methods scoped to a bare repo.
-/// Obtain one via `Repo::ci()`. Run lifecycle is on `Runs`, obtainable
-/// via `Repo::runs()`.
+/// Provides pipeline compilation and validation scoped to a bare
+/// repo. Obtain one via `Repo::ci()`. Run lifecycle is on `Runs`,
+/// obtainable via `Repo::runs()`.
pub struct Ci {
repo_path: PathBuf,
}
@@ -48,21 +48,22 @@ impl Ci {
Runs::new(runs_base)
}
- /// Load ci.fnl at a given SHA and return the validated pipeline.
+ /// Read and compile ci.fnl at a given SHA, returning the validated
+ /// pipeline.
///
- /// Pure parse and structural validation. Secrets are not needed
+ /// Pure compilation and structural validation. Secrets are not needed
/// here — they are passed to `Run::execute` since they only matter
/// when the run-fns actually fire.
///
/// Returns `Ok(None)` if the repo has no ci.fnl at that commit.
/// Errors if the Fennel source fails to parse/evaluate or if the
/// resulting job graph violates any structural rule.
- pub fn load(&self, commit: &CommitRef) -> Result<Option<Pipeline>> {
+ pub fn pipeline(&self, commit: &CommitRef) -> Result<Option<Pipeline>> {
let Some(source) = self.source(&commit.sha)? else {
return Ok(None);
};
let name = CI_FNL.to_string();
- let pipeline = Pipeline::load(&source, &name)?;
+ let pipeline = pipeline::compile(&source, &name)?;
Ok(Some(pipeline))
}
@@ -161,7 +162,7 @@ fn trigger_ref(
"created CI run"
);
- let pipeline = match Pipeline::load(&source, CI_FNL) {
+ let pipeline = match pipeline::compile(&source, CI_FNL) {
Ok(p) => p,
Err(e) => {
run.transition(RunState::Active)?;
@@ -265,7 +266,7 @@ mod tests {
}
#[test]
- fn ci_load_returns_none_when_no_ci_fnl() {
+ fn ci_pipeline_returns_none_when_no_ci_fnl() {
let (_dir, quire, name) = bare_repo_without_ci();
let repo = quire.repo(&name).expect("repo");
let ci = repo.ci();
@@ -274,12 +275,12 @@ mod tests {
sha: sha.clone(),
display: sha,
};
- let result = ci.load(&commit).expect("load should not error");
+ let result = ci.pipeline(&commit).expect("pipeline should not error");
assert!(result.is_none(), "no ci.fnl should return None");
}
#[test]
- fn ci_load_returns_pipeline_when_ci_fnl_present() {
+ fn ci_pipeline_returns_pipeline_when_ci_fnl_present() {
let source = r#"(local ci (require :quire.ci))
(ci.job :build [:quire/push] (fn [_] nil))"#;
let (_dir, quire, name) = bare_repo_with_ci(source);
@@ -291,15 +292,15 @@ mod tests {
display: sha,
};
let pipeline = ci
- .load(&commit)
- .expect("load should succeed")
+ .pipeline(&commit)
+ .expect("pipeline should succeed")
.expect("should have pipeline");
assert_eq!(pipeline.jobs().len(), 1);
assert_eq!(pipeline.jobs()[0].id, "build");
}
#[test]
- fn ci_load_errors_on_invalid_fennel() {
+ fn ci_pipeline_errors_on_invalid_fennel() {
let source = "{:bad {:}";
let (_dir, quire, name) = bare_repo_with_ci(source);
let repo = quire.repo(&name).expect("repo");
@@ -309,7 +310,7 @@ mod tests {
sha: sha.clone(),
display: sha,
};
- let result = ci.load(&commit);
+ let result = ci.pipeline(&commit);
assert!(result.is_err(), "bad Fennel should fail");
}
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index 4580316..bda9f67 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -1,5 +1,5 @@
-//! CI job graph: validation rules and the `load` entry point that
-//! parses a `ci.fnl` source string into a `Pipeline`.
+//! 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.
@@ -15,6 +15,66 @@ use super::lua;
use crate::Result;
use crate::fennel::Fennel;
+/// A registration-time error caught while individual `(ci.job …)` and
+/// `(ci.image …)` calls are being processed.
+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
+pub enum DefinitionError {
+ #[error(
+ "Job '{job_id}' has empty inputs. Pass [:quire/push] (or another input) so it has something to fire it."
+ )]
+ EmptyInputs {
+ job_id: String,
+ #[label("declared here")]
+ span: SourceSpan,
+ },
+
+ #[error("Job id '{job_id}' contains '/', which is reserved for the 'quire/' source namespace.")]
+ ReservedSlash {
+ job_id: String,
+ #[label("declared here")]
+ span: SourceSpan,
+ },
+
+ #[error("Pipeline image declared more than once.")]
+ DuplicateImage {
+ #[label("duplicate image declaration")]
+ span: SourceSpan,
+ },
+}
+
+/// A post-graph structural error found after all jobs have been
+/// registered and the dependency graph is built.
+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
+pub enum StructureError {
+ #[error("Cycle detected among jobs: {}", cycle_jobs.join(", "))]
+ Cycle {
+ cycle_jobs: Vec<String>,
+ #[label(collection, "in cycle")]
+ spans: Vec<SourceSpan>,
+ },
+
+ #[error("Job '{job_id}' is not reachable from any source ref (e.g. :quire/push).")]
+ Unreachable {
+ job_id: String,
+ #[label("declared here")]
+ span: SourceSpan,
+ },
+}
+
+/// A single diagnostic from pipeline compilation. Wraps the two
+/// error categories — definition-time and structure-time — so miette
+/// can iterate them via `#[related]`.
+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
+pub enum Diagnostic {
+ #[error(transparent)]
+ #[diagnostic(transparent)]
+ Definition(#[from] DefinitionError),
+
+ #[error(transparent)]
+ #[diagnostic(transparent)]
+ Structure(#[from] StructureError),
+}
+
/// Edges point from dependency to dependent. Node weights are indices
/// into `Pipeline::jobs`; source refs (e.g. `quire/push`) are not nodes.
type JobGraph = Graph<usize, ()>;
@@ -52,15 +112,15 @@ impl Job {
run_fn: mlua::Function,
line: u32,
source: &str,
- ) -> std::result::Result<Self, ValidationError> {
+ ) -> std::result::Result<Self, DefinitionError> {
let span = span_for_line(source, line);
if id.contains('/') {
- return Err(ValidationError::ReservedSlash { job_id: id, span });
+ return Err(DefinitionError::ReservedSlash { job_id: id, span });
}
if inputs.is_empty() {
- return Err(ValidationError::EmptyInputs { job_id: id, span });
+ return Err(DefinitionError::EmptyInputs { job_id: id, span });
}
Ok(Self {
@@ -75,12 +135,12 @@ impl Job {
/// A validated CI pipeline — a job graph that has passed all
/// structural rules.
///
-/// Obtain via [`Pipeline::load`], which parses the Fennel source and
+/// Obtain via [`compile`], which evaluates the Fennel source and
/// validates the result. Holding a `Pipeline` is proof that the graph
/// is sound.
///
/// Owns the Fennel/Lua VM so the registered `run_fn`s remain callable
-/// after `load` returns.
+/// after `compile` returns.
pub struct Pipeline {
jobs: Vec<Job>,
graph: JobGraph,
@@ -159,43 +219,6 @@ impl Pipeline {
}
result
}
-
- /// Parse and validate a ci.fnl source string into a `Pipeline`.
- ///
- /// 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(source: &str, name: &str) -> Result<Pipeline> {
- let fennel = Fennel::new()?;
- let parsed = lua::parse(&fennel, source, name)?;
-
- let mut errors = parsed.errors;
- let jobs = parsed.jobs;
- let image = parsed.image;
-
- let (graph, node_index) = build_graph(&jobs);
-
- if let Err(post) = validate_post_graph(&jobs, &graph) {
- errors.extend(post);
- }
-
- if errors.is_empty() {
- Ok(Self {
- jobs,
- graph,
- node_index,
- fennel,
- image,
- })
- } else {
- Err(LoadError {
- src: NamedSource::new(name, source.to_string()),
- errors,
- }
- .into())
- }
- }
}
/// Build the dependency graph for `jobs`. Inputs that don't match a
@@ -242,71 +265,71 @@ pub(super) fn span_for_line(source: &str, line: u32) -> SourceSpan {
SourceSpan::from((source.len(), 0)) // cov-excl-line
}
-/// A validation error found in the job graph.
-#[derive(Debug, thiserror::Error, miette::Diagnostic)]
-pub enum ValidationError {
- #[error("Cycle detected among jobs: {}", cycle_jobs.join(", "))]
- Cycle {
- cycle_jobs: Vec<String>,
- #[label(collection, "in cycle")]
- spans: Vec<SourceSpan>,
- },
-
- #[error(
- "Job '{job_id}' has empty inputs. Pass [:quire/push] (or another input) so it has something to fire it."
- )]
- EmptyInputs {
- job_id: String,
- #[label("declared here")]
- span: SourceSpan,
- },
-
- #[error("Job '{job_id}' is not reachable from any source ref (e.g. :quire/push).")]
- Unreachable {
- job_id: String,
- #[label("declared here")]
- span: SourceSpan,
- },
-
- #[error("Job id '{job_id}' contains '/', which is reserved for the 'quire/' source namespace.")]
- ReservedSlash {
- job_id: String,
- #[label("declared here")]
- span: SourceSpan,
- },
-
- #[error("Pipeline image declared more than once.")]
- DuplicateImage {
- #[label("duplicate image declaration")]
- span: SourceSpan,
- },
-}
-
-/// All validation errors produced while loading a ci.fnl, paired with
-/// the source so miette can render inline labels for each per-job
-/// error.
+/// All diagnostics produced while compiling a ci.fnl, paired with
+/// the source so miette can render inline labels for each diagnostic.
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
-#[error("CI validation failed")]
-pub struct LoadError {
+#[error("ci.fnl has errors")]
+pub struct PipelineError {
// Named `src` rather than `source` so thiserror doesn't auto-treat
// it as the error chain.
#[source_code]
pub src: NamedSource<String>,
#[related]
- pub errors: Vec<ValidationError>,
+ pub diagnostics: Vec<Diagnostic>,
+}
+
+/// Parse and validate a ci.fnl source string into a [`Pipeline`].
+///
+/// Delegates evaluation to [`lua::register`] for the Fennel-side work,
+/// then runs the post-graph rules over the surviving jobs. Any errors
+/// found are gathered into a single [`PipelineError`] carrying the
+/// source for miette to render with inline labels.
+pub(crate) fn compile(source: &str, name: &str) -> Result<Pipeline> {
+ let fennel = Fennel::new()?;
+ let registrations = lua::register(&fennel, source, name)?;
+
+ let mut diagnostics: Vec<Diagnostic> = registrations
+ .errors
+ .into_iter()
+ .map(Diagnostic::Definition)
+ .collect();
+ let jobs = registrations.jobs;
+ let image = registrations.image;
+
+ let (graph, node_index) = build_graph(&jobs);
+
+ if let Err(post) = validate_post_graph(&jobs, &graph) {
+ diagnostics.extend(post.into_iter().map(Diagnostic::Structure));
+ }
+
+ if diagnostics.is_empty() {
+ Ok(Pipeline {
+ jobs,
+ graph,
+ node_index,
+ fennel,
+ image,
+ })
+ } else {
+ Err(PipelineError {
+ src: NamedSource::new(name, source.to_string()),
+ diagnostics,
+ }
+ .into())
+ }
}
/// Run the post-graph validation rules — cycle detection and source
-/// reachability — over the surviving jobs from parsing.
+/// 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::parse`, so they are not re-checked
-/// here.
+/// `(ci.job …)` callback during `lua::register`, so they are not
+/// re-checked here.
fn validate_post_graph(
jobs: &[Job],
graph: &JobGraph,
-) -> std::result::Result<(), Vec<ValidationError>> {
+) -> std::result::Result<(), Vec<StructureError>> {
let mut errors = Vec::new();
let mut cycle_members: std::collections::HashSet<&str> = std::collections::HashSet::new();
@@ -325,7 +348,7 @@ fn validate_post_graph(
}
let cycle_jobs = members.iter().map(|j| j.id.clone()).collect();
let spans = members.iter().map(|j| j.span).collect();
- errors.push(ValidationError::Cycle { cycle_jobs, spans });
+ errors.push(StructureError::Cycle { cycle_jobs, spans });
}
// Rule 3: reachability — every job's transitive inputs must include a source ref.
@@ -361,7 +384,7 @@ fn validate_post_graph(
}
if !found_source {
- errors.push(ValidationError::Unreachable {
+ errors.push(StructureError::Unreachable {
job_id: job.id.clone(),
span: job.span,
});
@@ -380,10 +403,10 @@ mod tests {
use super::*;
#[test]
- fn load_registers_a_job() {
+ fn compile_registers_a_job() {
let source = r#"(local ci (require :quire.ci))
(ci.job :test [:quire/push] (fn [_] nil))"#;
- let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+ let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
let jobs = pipeline.jobs();
assert_eq!(jobs.len(), 1);
assert_eq!(jobs[0].id, "test");
@@ -391,13 +414,13 @@ mod tests {
}
#[test]
- fn load_registers_multiple_jobs() {
+ fn compile_registers_multiple_jobs() {
let source = r#"
(local ci (require :quire.ci))
(ci.job :build [:quire/push] (fn [_] nil))
(ci.job :test [:build] (fn [_] nil))
"#;
- let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+ let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
let jobs = pipeline.jobs();
assert_eq!(jobs.len(), 2);
assert_eq!(jobs[0].id, "build");
@@ -407,14 +430,14 @@ mod tests {
}
#[test]
- fn load_captures_source_line() {
+ fn compile_captures_source_line() {
let source = "(local ci (require :quire.ci))
(ci.job :first [:quire/push] (fn [_] nil))
(ci.job :second [:quire/push] (fn [_] nil))
(ci.job :sixth [:quire/push] (fn [_] nil))";
- let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+ let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
let lines: Vec<usize> = pipeline
.jobs()
.iter()
@@ -424,36 +447,36 @@ mod tests {
}
#[test]
- fn load_errors_on_bad_fennel() {
- let result = Pipeline::load("{:bad {:}", "ci.fnl");
+ fn compile_errors_on_bad_fennel() {
+ let result = compile("{:bad {:}", "ci.fnl");
assert!(result.is_err(), "malformed Fennel should fail");
}
- /// Parse a Fennel source into jobs and errors. The local Fennel is
- /// dropped on return, but the returned `Job`s only need their
- /// non-VM fields here.
- fn parse_jobs(source: &str) -> (Vec<Job>, Vec<ValidationError>) {
+ /// Register a Fennel source, returning the jobs and any
+ /// definition-time errors. The local Fennel is dropped on return,
+ /// but the returned `Job`s only need their non-VM fields here.
+ fn register_jobs(source: &str) -> (Vec<Job>, Vec<DefinitionError>) {
let f = Fennel::new().expect("Fennel::new() should succeed");
- let output = lua::parse(&f, source, "ci.fnl").expect("parse should succeed");
+ let output = lua::register(&f, source, "ci.fnl").expect("register should succeed");
(output.jobs, output.errors)
}
- /// Discard parse errors and return only the successfully registered
- /// jobs — for tests that exercise post-graph rules.
- fn parsed_jobs(source: &str) -> Vec<Job> {
- parse_jobs(source).0
+ /// Discard registration errors and return only the successfully
+ /// registered jobs — for tests that exercise post-graph rules.
+ fn registered_jobs(source: &str) -> Vec<Job> {
+ register_jobs(source).0
}
/// Run post-graph validation against `jobs`, building the dependency
- /// graph the same way `load` does.
- fn validate(jobs: &[Job]) -> std::result::Result<(), Vec<ValidationError>> {
+ /// graph the same way `compile` does.
+ fn validate(jobs: &[Job]) -> std::result::Result<(), Vec<StructureError>> {
let (graph, _) = build_graph(jobs);
validate_post_graph(jobs, &graph)
}
#[test]
fn validate_accepts_valid_config() {
- let jobs = parsed_jobs(
+ let jobs = registered_jobs(
r#"
(local ci (require :quire.ci))
(ci.job :build [:quire/push] (fn [_] nil))
@@ -465,7 +488,7 @@ mod tests {
#[test]
fn validate_rejects_cycle() {
- let jobs = parsed_jobs(
+ let jobs = registered_jobs(
r#"
(local ci (require :quire.ci))
(ci.job :a [:b] (fn [_] nil))
@@ -474,14 +497,14 @@ mod tests {
);
let errs = validate(&jobs).unwrap_err();
assert!(
- errs.iter().any(|e| matches!(e, ValidationError::Cycle { cycle_jobs, .. } if cycle_jobs.contains(&"a".to_string()) && cycle_jobs.contains(&"b".to_string()))),
+ errs.iter().any(|e| matches!(e, StructureError::Cycle { cycle_jobs, .. } if cycle_jobs.contains(&"a".to_string()) && cycle_jobs.contains(&"b".to_string()))),
"should report a cycle involving a and b: {errs:?}"
);
}
#[test]
fn validate_cycle_only_reports_cycle_members() {
- let jobs = parsed_jobs(
+ let jobs = registered_jobs(
r#"
(local ci (require :quire.ci))
(ci.job :a [:b :quire/push] (fn [_] nil))
@@ -493,7 +516,7 @@ mod tests {
let cycle_errs: Vec<&Vec<String>> = errs
.iter()
.filter_map(|e| match e {
- ValidationError::Cycle { cycle_jobs, .. } => Some(cycle_jobs),
+ StructureError::Cycle { cycle_jobs, .. } => Some(cycle_jobs),
_ => None, // cov-excl-line
})
.collect();
@@ -507,7 +530,7 @@ mod tests {
#[test]
fn validate_reports_disjoint_cycles_separately() {
- let jobs = parsed_jobs(
+ let jobs = registered_jobs(
r#"
(local ci (require :quire.ci))
(ci.job :a [:b :quire/push] (fn [_] nil))
@@ -519,34 +542,34 @@ mod tests {
let errs = validate(&jobs).unwrap_err();
let cycle_count = errs
.iter()
- .filter(|e| matches!(e, ValidationError::Cycle { .. }))
+ .filter(|e| matches!(e, StructureError::Cycle { .. }))
.count();
assert_eq!(cycle_count, 2, "expected two cycle errors: {errs:?}");
}
#[test]
- fn parse_rejects_empty_inputs() {
- let (_, errors) = parse_jobs(
+ fn register_rejects_empty_inputs() {
+ let (_, errors) = register_jobs(
r#"(local ci (require :quire.ci))
(ci.job :setup [] (fn [_] nil))"#,
);
assert!(
errors.iter().any(
- |e| matches!(e, ValidationError::EmptyInputs { job_id, .. } if job_id == "setup")
+ |e| matches!(e, DefinitionError::EmptyInputs { job_id, .. } if job_id == "setup")
),
"should report empty inputs for 'setup': {errors:?}"
);
}
#[test]
- fn parse_rejects_slash_in_job_id() {
- let (_, errors) = parse_jobs(
+ fn register_rejects_slash_in_job_id() {
+ let (_, errors) = register_jobs(
r#"(local ci (require :quire.ci))
(ci.job :foo/bar [:quire/push] (fn [_] nil))"#,
);
assert!(
errors.iter().any(
- |e| matches!(e, ValidationError::ReservedSlash { job_id, .. } if job_id == "foo/bar")
+ |e| matches!(e, DefinitionError::ReservedSlash { job_id, .. } if job_id == "foo/bar")
),
"should report slash in job id: {errors:?}"
);
@@ -556,7 +579,7 @@ mod tests {
fn validate_does_not_double_report_cycle_as_unreachable() {
// Jobs in a cycle are technically also unreachable from any
// source ref, but reporting both is noise. Cycle alone is enough.
- let jobs = parsed_jobs(
+ let jobs = registered_jobs(
r#"
(local ci (require :quire.ci))
(ci.job :a [:b] (fn [_] nil))
@@ -566,7 +589,7 @@ mod tests {
let errs = validate(&jobs).unwrap_err();
let unreachable_count = errs
.iter()
- .filter(|e| matches!(e, ValidationError::Unreachable { .. }))
+ .filter(|e| matches!(e, StructureError::Unreachable { .. }))
.count();
assert_eq!(
unreachable_count, 0,
@@ -579,14 +602,14 @@ mod tests {
// A job whose only input names a non-existent job passes
// pre-graph rules (inputs is non-empty, id has no slash) and
// reaches the post-graph reachability check.
- let jobs = parsed_jobs(
+ let jobs = registered_jobs(
r#"(local ci (require :quire.ci))
(ci.job :orphan [:does-not-exist] (fn [_] nil))"#,
);
let errs = validate(&jobs).unwrap_err();
assert!(
errs.iter().any(
- |e| matches!(e, ValidationError::Unreachable { job_id, .. } if job_id == "orphan")
+ |e| matches!(e, StructureError::Unreachable { job_id, .. } if job_id == "orphan")
),
"should report unreachable job 'orphan': {errs:?}"
);
@@ -597,7 +620,7 @@ mod tests {
// Diamond: push -> a -> b -> d, push -> a -> c -> d.
// `d` is reachable and `a` is visited multiple times
// through different paths.
- let jobs = parsed_jobs(
+ let jobs = registered_jobs(
r#"
(local ci (require :quire.ci))
(ci.job :a [:quire/push] (fn [_] nil))
@@ -614,7 +637,7 @@ mod tests {
// stack ["a", "a"], pop a (visit), push nothing (a isn't a job),
// stack ["a"], pop a → already visited → continue.
// The dedup fires because `a` isn't a job and isn't a source.
- let jobs = parsed_jobs(
+ let jobs = registered_jobs(
r#"
(local ci (require :quire.ci))
(ci.job :orphan [:a :a] (fn [_] nil))"#,
@@ -622,21 +645,21 @@ mod tests {
let errs = validate(&jobs).unwrap_err();
assert!(
errs.iter()
- .any(|e| matches!(e, ValidationError::Unreachable { .. })),
+ .any(|e| matches!(e, StructureError::Unreachable { .. })),
"expected unreachable: {errs:?}"
);
}
#[test]
fn transitive_inputs_collects_direct_and_indirect() {
- let pipeline = Pipeline::load(
+ let pipeline = compile(
r#"(local ci (require :quire.ci))
(ci.job :setup [:quire/push] (fn [_] nil))
(ci.job :build [:setup] (fn [_] nil))
(ci.job :test [:build :setup] (fn [_] nil))"#,
"ci.fnl",
)
- .expect("load should succeed");
+ .expect("compile should succeed");
let map = pipeline.transitive_inputs();
@@ -662,47 +685,47 @@ mod tests {
#[test]
fn transitive_inputs_excludes_self() {
- let pipeline = Pipeline::load(
+ let pipeline = compile(
r#"(local ci (require :quire.ci))
(ci.job :only [:quire/push] (fn [_] nil))"#,
"ci.fnl",
)
- .expect("load should succeed");
+ .expect("compile should succeed");
let map = pipeline.transitive_inputs();
assert!(!map["only"].contains("only"), "self should not be in set");
}
#[test]
- fn load_registers_pipeline_image() {
+ fn compile_registers_pipeline_image() {
let source = r#"(local ci (require :quire.ci))
(ci.image "alpine")
(ci.job :build [:quire/push] (fn [_] nil))"#;
- let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+ let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
assert_eq!(pipeline.image(), Some("alpine"));
}
#[test]
- fn load_succeeds_without_image() {
+ fn compile_succeeds_without_image() {
let source = r#"(local ci (require :quire.ci))
(ci.job :build [:quire/push] (fn [_] nil))"#;
- let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+ let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
assert_eq!(pipeline.image(), None);
}
#[test]
fn duplicate_image_variant_exists() {
- let err = ValidationError::DuplicateImage {
+ let err = DefinitionError::DuplicateImage {
span: SourceSpan::from((0, 0)),
};
assert!(err.to_string().contains("image"));
}
#[test]
- fn load_reports_both_parse_and_post_graph_errors() {
+ fn compile_reports_both_definition_and_structure_errors() {
// `setup` has empty inputs (pre-graph error) and `orphan` is unreachable
// (post-graph error). Both should be reported.
- let result = Pipeline::load(
+ let result = compile(
r#"(local ci (require :quire.ci))
(ci.job :setup [] (fn [_] nil))
(ci.job :orphan [:does-not-exist] (fn [_] nil))"#,
@@ -711,24 +734,24 @@ mod tests {
let Err(e) = result else { unreachable!() };
let msg = e.to_string();
assert!(
- msg.contains("CI validation failed"),
- "expected validation error: {msg}"
+ msg.contains("ci.fnl has errors"),
+ "expected pipeline error: {msg}"
);
}
#[test]
- fn load_errors_on_duplicate_image() {
+ fn compile_errors_on_duplicate_image() {
let source = r#"(local ci (require :quire.ci))
(ci.image "alpine")
(ci.image "ubuntu")
(ci.job :build [:quire/push] (fn [_] nil))"#;
- let result = Pipeline::load(source, "ci.fnl");
+ let result = compile(source, "ci.fnl");
assert!(result.is_err(), "duplicate image should fail");
let Err(e) = result else { unreachable!() };
let msg = e.to_string();
assert!(
- msg.contains("CI validation failed"),
- "expected validation error: {msg}"
+ msg.contains("ci.fnl has errors"),
+ "expected pipeline error: {msg}"
);
}
}
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 0e2ed8e..d23c967 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -786,7 +786,7 @@ mod tests {
}
fn load(source: &str) -> Pipeline {
- super::super::pipeline::Pipeline::load(source, "ci.fnl").expect("load should succeed")
+ super::super::pipeline::compile(source, "ci.fnl").expect("compile should succeed")
}
#[test]
diff --git a/src/error.rs b/src/error.rs
index 3ace9e1..f7c7b3e 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -1,6 +1,6 @@
use miette::Diagnostic;
-use crate::ci::{LoadError, RunState};
+use crate::ci::{PipelineError, RunState};
use crate::fennel::FennelError;
#[derive(Debug, thiserror::Error, Diagnostic)]
@@ -25,7 +25,7 @@ pub enum Error {
#[error(transparent)]
#[diagnostic(transparent)]
- Validation(Box<LoadError>),
+ Pipeline(Box<PipelineError>),
#[error("invalid run transition: {from:?} -> {to:?}")]
InvalidTransition { from: RunState, to: RunState },
@@ -55,9 +55,9 @@ impl From<FennelError> for Error {
}
}
-impl From<LoadError> for Error {
- fn from(err: LoadError) -> Self {
- Error::Validation(Box::new(err))
+impl From<PipelineError> for Error {
+ fn from(err: PipelineError) -> Self {
+ Error::Pipeline(Box::new(err))
}
}
@@ -76,16 +76,18 @@ mod tests {
}
#[test]
- fn from_load_error() {
+ fn from_pipeline_error() {
let source = "(ci.job :a [] (fn [_] nil))";
- let load_err = LoadError {
+ let pipeline_err = PipelineError {
src: miette::NamedSource::new("ci.fnl", source.to_string()),
- errors: vec![crate::ci::ValidationError::EmptyInputs {
- job_id: "a".to_string(),
- span: miette::SourceSpan::from((0, 0)),
- }],
+ diagnostics: vec![crate::ci::Diagnostic::Definition(
+ crate::ci::DefinitionError::EmptyInputs {
+ job_id: "a".to_string(),
+ span: miette::SourceSpan::from((0, 0)),
+ },
+ )],
};
- let err: Error = load_err.into();
- assert!(err.to_string().contains("CI validation failed"));
+ let err: Error = pipeline_err.into();
+ assert!(err.to_string().contains("ci.fnl has errors"));
}
}