Rename EvalResult to Pipeline, JobDef to Job, graph to pipeline
The loaded ci.fnl is now a Pipeline holding Jobs with their run
functions. Pipeline::validate() checks structural rules. The graph
module is renamed to pipeline.
Assisted-by: GLM-5.1 via pi
diff --git a/src/bin/quire/commands/ci.rs b/src/bin/quire/commands/ci.rs
index 4a38a58..61450ef 100644
--- a/src/bin/quire/commands/ci.rs
+++ b/src/bin/quire/commands/ci.rs
@@ -5,7 +5,7 @@ use quire::ci::Ci;
/// Validate a repo's ci.fnl without executing any jobs.
///
-/// Evaluates the Fennel source at the given SHA (or HEAD) to extract
+/// Loads the Fennel source at the given SHA (or HEAD) to extract
/// the job registration table, then runs the four structural validations.
/// Prints each job found and any validation errors.
pub async fn validate(sha: Option<&str>) -> Result<()> {
@@ -13,23 +13,24 @@ pub async fn validate(sha: Option<&str>) -> Result<()> {
let sha = sha.unwrap_or("HEAD");
let ci = Ci::new(repo_path);
- let Some(result) = ci.load(sha)? else {
+ let Some(pipeline) = ci.load(sha)? else {
println!("No ci.fnl found at {sha}.");
return Ok(());
};
- if result.jobs.is_empty() {
+ let jobs = pipeline.jobs();
+ if jobs.is_empty() {
println!("No jobs registered.");
return Ok(());
}
println!("Jobs:");
- for job in &result.jobs {
+ for job in jobs {
let inputs = job.inputs.join(", ");
println!(" {} ← [{}]", job.id, inputs);
}
- match quire::ci::validate(&result.jobs) {
+ match pipeline.validate() {
Ok(()) => {
println!("\nAll validations passed.");
}
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index f8d7198..4fc5691 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -1,9 +1,9 @@
//! CI: trigger runs from push events, validate the job graph.
-pub mod graph;
+pub mod pipeline;
pub mod run;
-pub use graph::{EvalResult, JobDef, ValidationError, eval_ci, validate};
+pub use pipeline::{Job, Pipeline, ValidationError};
pub use run::{Run, RunMeta, RunState, RunTimes, Runs};
use std::path::PathBuf;
@@ -17,7 +17,7 @@ pub const CI_FNL: &str = ".quire/ci.fnl";
/// Access to CI operations for a single repo.
///
-/// Provides eval and validation methods scoped to a bare 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()`.
pub struct Ci {
@@ -34,28 +34,28 @@ impl Ci {
Runs::new(runs_base)
}
- /// Evaluate ci.fnl at a given SHA and return the registration table.
+ /// Load ci.fnl at a given SHA and return the parsed pipeline.
///
/// Returns `Ok(None)` if the repo has no ci.fnl at that commit.
- pub fn load(&self, sha: &str) -> Result<Option<EvalResult>> {
+ pub fn load(&self, sha: &str) -> Result<Option<Pipeline>> {
let Some(source) = self.source(sha)? else {
return Ok(None);
};
let fennel = crate::fennel::Fennel::new()?;
let name = format!("{sha}:{CI_FNL}");
- let result = eval_ci(&fennel, &source, &name)?;
- Ok(Some(result))
+ let pipeline = pipeline::eval_ci(&fennel, &source, &name)?;
+ Ok(Some(pipeline))
}
- /// Evaluate ci.fnl at a given SHA and validate the job graph.
+ /// Load ci.fnl at a given SHA and validate the pipeline.
///
/// Returns `Ok(None)` if the repo has no ci.fnl at that commit.
- pub fn validate_at(&self, sha: &str) -> Result<Option<EvalResult>> {
- let Some(result) = self.load(sha)? else {
+ pub fn validate_at(&self, sha: &str) -> Result<Option<Pipeline>> {
+ let Some(pipeline) = self.load(sha)? else {
return Ok(None);
};
- validate(&result.jobs)?;
- Ok(Some(result))
+ pipeline.validate()?;
+ Ok(Some(pipeline))
}
/// Read the contents of `.quire/ci.fnl` at a given commit SHA.
@@ -70,9 +70,6 @@ impl Ci {
.output()?;
if !output.status.success() {
- // Distinguish "file not found" from real errors. git show
- // exits 128 for missing paths but the stderr contains
- // "does not exist" — check for that pattern.
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("does not exist") || stderr.contains("not found") {
return Ok(None);
@@ -145,10 +142,9 @@ fn trigger_ref(repo: &Repo, pushed_at: jiff::Timestamp, push_ref: &PushRef) -> R
run.transition(RunState::Active)?;
- // Evaluate and validate the source we already read.
let fennel = crate::fennel::Fennel::new()?;
let name = format!("{}:{CI_FNL}", push_ref.new_sha);
- let result = match eval_ci(&fennel, &source, &name) {
+ let pipeline = match pipeline::eval_ci(&fennel, &source, &name) {
Ok(r) => r,
Err(e) => {
run.transition(RunState::Failed)?;
@@ -156,7 +152,7 @@ fn trigger_ref(repo: &Repo, pushed_at: jiff::Timestamp, push_ref: &PushRef) -> R
}
};
- match validate(&result.jobs) {
+ match pipeline.validate() {
Ok(()) => {
run.transition(RunState::Complete)?;
}
diff --git a/src/ci/graph.rs b/src/ci/pipeline.rs
similarity index 74%
rename from src/ci/graph.rs
rename to src/ci/pipeline.rs
index 8355437..faa0b09 100644
--- a/src/ci/graph.rs
+++ b/src/ci/pipeline.rs
@@ -3,22 +3,44 @@
use crate::Result;
use crate::fennel::{Fennel, FennelError};
-/// A registered job definition extracted from ci.fnl.
-pub struct JobDef {
+/// A registered job extracted from ci.fnl.
+pub struct Job {
pub id: String,
pub inputs: Vec<String>,
+ /// The job's run function from the Lua VM.
+ /// Stored for future execution — not yet called.
+ #[expect(dead_code)]
+ pub(crate) run_fn: mlua::Function,
}
-/// The result of evaluating a ci.fnl file.
-pub struct EvalResult {
- pub jobs: Vec<JobDef>,
+/// A loaded CI pipeline — the parsed job graph from ci.fnl.
+///
+/// Returned by `Ci::load`. Holds the registered jobs and their
+/// run functions. Call `validate` to check structural rules before
+/// execution.
+pub struct Pipeline {
+ pub(crate) jobs: Vec<Job>,
+}
+
+impl Pipeline {
+ pub fn jobs(&self) -> &[Job] {
+ &self.jobs
+ }
+
+ /// Validate the structural rules of this pipeline.
+ ///
+ /// Returns `Ok(())` if all four rules pass, or `Err` with all
+ /// violations found.
+ pub fn validate(&self) -> std::result::Result<(), Vec<ValidationError>> {
+ validate(&self.jobs)
+ }
}
/// Evaluate a ci.fnl source string, registering jobs via the `job` macro.
///
/// Injects a `job` global that accumulates into a registration table,
/// evaluates the source, and extracts the registered jobs.
-pub fn eval_ci(fennel: &Fennel, source: &str, name: &str) -> Result<EvalResult> {
+pub(crate) fn eval_ci(fennel: &Fennel, source: &str, name: &str) -> Result<Pipeline> {
fennel.eval_raw(source, name, |lua| {
// Create a registration table. `job` will push into this.
let registry: mlua::Table = lua.create_table()?;
@@ -49,14 +71,15 @@ pub fn eval_ci(fennel: &Fennel, source: &str, name: &str) -> Result<EvalResult>
let entry = entry.map_err(lua_err)?;
let id: String = entry.get("id").map_err(lua_err)?;
let inputs_table: mlua::Table = entry.get("inputs").map_err(lua_err)?;
+ let run_fn: mlua::Function = entry.get("run").map_err(lua_err)?;
let mut inputs = Vec::new();
for input in inputs_table.sequence_values::<String>() {
inputs.push(input.map_err(lua_err)?);
}
- jobs.push(JobDef { id, inputs });
+ jobs.push(Job { id, inputs, run_fn });
}
- Ok(EvalResult { jobs })
+ Ok(Pipeline { jobs })
}
/// A validation error found in the job graph.
@@ -80,7 +103,7 @@ pub enum ValidationError {
/// Validate the structural rules of a job graph.
///
/// Returns `Ok(())` if all four rules pass, or `Err` with all violations found.
-pub fn validate(jobs: &[JobDef]) -> std::result::Result<(), Vec<ValidationError>> {
+fn validate(jobs: &[Job]) -> std::result::Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
// Rule 4: no '/' in user job ids.
@@ -214,32 +237,24 @@ mod tests {
#[test]
fn validate_accepts_valid_config() {
- let jobs = vec![
- JobDef {
- id: "build".into(),
- inputs: vec!["quire/push".into()],
- },
- JobDef {
- id: "test".into(),
- inputs: vec!["build".into(), "quire/push".into()],
- },
- ];
- assert!(validate(&jobs).is_ok());
+ let f = fennel();
+ let source = r#"
+(job :build [:quire/push] (fn [_] nil))
+(job :test [:build :quire/push] (fn [_] nil))
+"#;
+ let pipeline = eval_ci(&f, source, "ci.fnl").expect("eval should succeed");
+ assert!(pipeline.validate().is_ok());
}
#[test]
fn validate_rejects_cycle() {
- let jobs = vec![
- JobDef {
- id: "a".into(),
- inputs: vec!["b".into()],
- },
- JobDef {
- id: "b".into(),
- inputs: vec!["a".into()],
- },
- ];
- let errs = validate(&jobs).unwrap_err();
+ let f = fennel();
+ let source = r#"
+(job :a [:b] (fn [_] nil))
+(job :b [:a] (fn [_] nil))
+"#;
+ let pipeline = eval_ci(&f, source, "ci.fnl").expect("eval should succeed");
+ let errs = pipeline.validate().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()))),
"should report a cycle involving a and b: {errs:?}"
@@ -248,23 +263,14 @@ mod tests {
#[test]
fn validate_cycle_only_reports_cycle_members() {
- // `clean` is acyclic; `a` and `b` form a cycle. Only a/b should be
- // flagged, and `clean` must not appear in any Cycle error.
- let jobs = vec![
- JobDef {
- id: "a".into(),
- inputs: vec!["b".into(), "quire/push".into()],
- },
- JobDef {
- id: "b".into(),
- inputs: vec!["a".into(), "quire/push".into()],
- },
- JobDef {
- id: "clean".into(),
- inputs: vec!["quire/push".into()],
- },
- ];
- let errs = validate(&jobs).unwrap_err();
+ let f = fennel();
+ let source = r#"
+(job :a [:b :quire/push] (fn [_] nil))
+(job :b [:a :quire/push] (fn [_] nil))
+(job :clean [:quire/push] (fn [_] nil))
+"#;
+ let pipeline = eval_ci(&f, source, "ci.fnl").expect("eval should succeed");
+ let errs = pipeline.validate().unwrap_err();
let cycle_errs: Vec<&Vec<String>> = errs
.iter()
.filter_map(|e| match e {
@@ -282,26 +288,15 @@ mod tests {
#[test]
fn validate_reports_disjoint_cycles_separately() {
- // Two independent cycles: (a <-> b) and (c <-> d).
- let jobs = vec![
- JobDef {
- id: "a".into(),
- inputs: vec!["b".into(), "quire/push".into()],
- },
- JobDef {
- id: "b".into(),
- inputs: vec!["a".into(), "quire/push".into()],
- },
- JobDef {
- id: "c".into(),
- inputs: vec!["d".into(), "quire/push".into()],
- },
- JobDef {
- id: "d".into(),
- inputs: vec!["c".into(), "quire/push".into()],
- },
- ];
- let errs = validate(&jobs).unwrap_err();
+ let f = fennel();
+ let source = r#"
+(job :a [:b :quire/push] (fn [_] nil))
+(job :b [:a :quire/push] (fn [_] nil))
+(job :c [:d :quire/push] (fn [_] nil))
+(job :d [:c :quire/push] (fn [_] nil))
+"#;
+ let pipeline = eval_ci(&f, source, "ci.fnl").expect("eval should succeed");
+ let errs = pipeline.validate().unwrap_err();
let cycle_count = errs
.iter()
.filter(|e| matches!(e, ValidationError::Cycle { .. }))
@@ -311,11 +306,10 @@ mod tests {
#[test]
fn validate_rejects_empty_inputs() {
- let jobs = vec![JobDef {
- id: "setup".into(),
- inputs: vec![],
- }];
- let errs = validate(&jobs).unwrap_err();
+ let f = fennel();
+ let source = r#"(job :setup [] (fn [_] nil))"#;
+ let pipeline = eval_ci(&f, source, "ci.fnl").expect("eval should succeed");
+ let errs = pipeline.validate().unwrap_err();
assert!(
errs.iter()
.any(|e| matches!(e, ValidationError::EmptyInputs { job_id } if job_id == "setup")),
@@ -325,11 +319,10 @@ mod tests {
#[test]
fn validate_rejects_unreachable_jobs() {
- let jobs = vec![JobDef {
- id: "orphan".into(),
- inputs: vec!["orphan".into()],
- }];
- let errs = validate(&jobs).unwrap_err();
+ let f = fennel();
+ let source = r#"(job :orphan [:orphan] (fn [_] nil))"#;
+ let pipeline = eval_ci(&f, source, "ci.fnl").expect("eval should succeed");
+ let errs = pipeline.validate().unwrap_err();
assert!(
errs.iter().any(
|e| matches!(e, ValidationError::Unreachable { job_id } if job_id == "orphan")
@@ -340,11 +333,10 @@ mod tests {
#[test]
fn validate_rejects_slash_in_job_id() {
- let jobs = vec![JobDef {
- id: "foo/bar".into(),
- inputs: vec!["quire/push".into()],
- }];
- let errs = validate(&jobs).unwrap_err();
+ let f = fennel();
+ let source = r#"(job :foo/bar [:quire/push] (fn [_] nil))"#;
+ let pipeline = eval_ci(&f, source, "ci.fnl").expect("eval should succeed");
+ let errs = pipeline.validate().unwrap_err();
assert!(
errs.iter().any(
|e| matches!(e, ValidationError::ReservedSlash { job_id } if job_id == "foo/bar")