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
change xpqzttvutrrzolszskpwyomtukutkzto
commit ee9813237ff1e7afc9ef7b183e3108d8cbacd0e1
author Alpha Chen <alpha@kejadlen.dev>
date
parent qxlwplux
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")