Split ci into run, graph, and trigger submodules
Run storage, graph eval/validate, and trigger orchestration
share nothing but the namespace. Split them; re-exports keep
the external surface unchanged.
Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
diff --git a/src/ci/graph.rs b/src/ci/graph.rs
new file mode 100644
index 0000000..8355437
--- /dev/null
+++ b/src/ci/graph.rs
@@ -0,0 +1,355 @@
+//! CI job graph: evaluation of `ci.fnl` and validation rules.
+
+use crate::Result;
+use crate::fennel::{Fennel, FennelError};
+
+/// A registered job definition extracted from ci.fnl.
+pub struct JobDef {
+ pub id: String,
+ pub inputs: Vec<String>,
+}
+
+/// The result of evaluating a ci.fnl file.
+pub struct EvalResult {
+ pub jobs: Vec<JobDef>,
+}
+
+/// 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> {
+ fennel.eval_raw(source, name, |lua| {
+ // Create a registration table. `job` will push into this.
+ let registry: mlua::Table = lua.create_table()?;
+ lua.globals().set("_quire_jobs", registry)?;
+
+ // Define the `job` global: (job id inputs run-fn)
+ let job_fn = lua.create_function(
+ |lua, (id, inputs, run_fn): (mlua::String, mlua::Table, mlua::Function)| {
+ let registry: mlua::Table = lua.globals().get("_quire_jobs")?;
+ let entry = lua.create_table()?;
+ entry.set("id", id)?;
+ entry.set("inputs", inputs)?;
+ entry.set("run", run_fn)?;
+ registry.push(entry)?;
+ Ok(())
+ },
+ )?;
+ lua.globals().set("job", job_fn)?;
+
+ Ok(())
+ })?;
+
+ // Extract the registration table.
+ let lua_err = |e: mlua::Error| FennelError::from_lua(source, name, e);
+ let registry: mlua::Table = fennel.lua().globals().get("_quire_jobs").map_err(lua_err)?;
+ let mut jobs = Vec::new();
+ for entry in registry.sequence_values::<mlua::Table>() {
+ 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 mut inputs = Vec::new();
+ for input in inputs_table.sequence_values::<String>() {
+ inputs.push(input.map_err(lua_err)?);
+ }
+ jobs.push(JobDef { id, inputs });
+ }
+
+ Ok(EvalResult { jobs })
+}
+
+/// 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> },
+
+ #[error(
+ "Job '{job_id}' has empty inputs. Pass [:quire/push] (or another input) so it has something to fire it."
+ )]
+ EmptyInputs { job_id: String },
+
+ #[error("Job '{job_id}' is not reachable from any source ref (e.g. :quire/push).")]
+ Unreachable { job_id: String },
+
+ #[error("Job id '{job_id}' contains '/', which is reserved for the 'quire/' source namespace.")]
+ ReservedSlash { job_id: String },
+}
+
+/// 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>> {
+ let mut errors = Vec::new();
+
+ // Rule 4: no '/' in user job ids.
+ for job in jobs {
+ if job.id.contains('/') {
+ errors.push(ValidationError::ReservedSlash {
+ job_id: job.id.clone(),
+ });
+ }
+ }
+
+ // Rule 2: non-empty inputs.
+ for job in jobs {
+ if job.inputs.is_empty() {
+ errors.push(ValidationError::EmptyInputs {
+ job_id: job.id.clone(),
+ });
+ }
+ }
+
+ // Rule 1: acyclic.
+ //
+ // Build a directed graph where edges point from dependency to
+ // dependent. Source refs (e.g. "quire/push") are not nodes.
+ let mut graph: petgraph::Graph<&str, ()> = petgraph::Graph::new();
+ let mut node_map: std::collections::HashMap<&str, petgraph::graph::NodeIndex> =
+ std::collections::HashMap::new();
+
+ for job in jobs {
+ let idx = graph.add_node(job.id.as_str());
+ node_map.insert(job.id.as_str(), idx);
+ }
+
+ for job in jobs {
+ let dependent = node_map[job.id.as_str()];
+ for input in &job.inputs {
+ if let Some(&dependency) = node_map.get(input.as_str()) {
+ graph.add_edge(dependency, dependent, ());
+ }
+ }
+ }
+
+ // Each non-trivial strongly connected component is a distinct cycle.
+ // A single-node SCC is only a cycle if it has a self-edge.
+ for scc in petgraph::algo::tarjan_scc(&graph) {
+ let is_cycle = scc.len() > 1 || (scc.len() == 1 && graph.contains_edge(scc[0], scc[0]));
+ if !is_cycle {
+ continue;
+ }
+ let mut cycle_jobs: Vec<String> = scc.iter().map(|&idx| graph[idx].to_string()).collect();
+ cycle_jobs.sort();
+ errors.push(ValidationError::Cycle { cycle_jobs });
+ }
+
+ // Rule 3: reachability — every job's transitive inputs must include a source ref.
+ let is_source = |name: &str| name.starts_with("quire/");
+
+ for job in jobs {
+ let mut visited = std::collections::HashSet::new();
+ let mut stack: Vec<&str> = job.inputs.iter().map(|s| s.as_str()).collect();
+ let mut found_source = false;
+
+ while let Some(name) = stack.pop() {
+ if !visited.insert(name) {
+ continue;
+ }
+ if is_source(name) {
+ found_source = true;
+ break;
+ }
+ if let Some(upstream) = jobs.iter().find(|j| j.id == name) {
+ for input in &upstream.inputs {
+ stack.push(input.as_str());
+ }
+ }
+ }
+
+ if !found_source {
+ errors.push(ValidationError::Unreachable {
+ job_id: job.id.clone(),
+ });
+ }
+ }
+
+ if errors.is_empty() {
+ Ok(())
+ } else {
+ Err(errors)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn fennel() -> Fennel {
+ Fennel::new().expect("Fennel::new() should succeed")
+ }
+
+ #[test]
+ fn eval_ci_registers_a_job() {
+ let f = fennel();
+ let source = r#"(job :test [:quire/push] (fn [_] nil))"#;
+ let result = eval_ci(&f, source, "ci.fnl").expect("eval should succeed");
+ assert_eq!(result.jobs.len(), 1);
+ assert_eq!(result.jobs[0].id, "test");
+ assert_eq!(result.jobs[0].inputs, vec!["quire/push"]);
+ }
+
+ #[test]
+ fn eval_ci_registers_multiple_jobs() {
+ let f = fennel();
+ let source = r#"
+(job :build [:quire/push] (fn [_] nil))
+(job :test [:build] (fn [_] nil))
+"#;
+ let result = eval_ci(&f, source, "ci.fnl").expect("eval should succeed");
+ assert_eq!(result.jobs.len(), 2);
+ assert_eq!(result.jobs[0].id, "build");
+ assert_eq!(result.jobs[0].inputs, vec!["quire/push"]);
+ assert_eq!(result.jobs[1].id, "test");
+ assert_eq!(result.jobs[1].inputs, vec!["build"]);
+ }
+
+ #[test]
+ fn eval_ci_errors_on_bad_fennel() {
+ let f = fennel();
+ let result = eval_ci(&f, "{:bad {:}", "ci.fnl");
+ assert!(result.is_err(), "malformed Fennel should fail");
+ }
+
+ #[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());
+ }
+
+ #[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();
+ 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:?}"
+ );
+ }
+
+ #[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 cycle_errs: Vec<&Vec<String>> = errs
+ .iter()
+ .filter_map(|e| match e {
+ ValidationError::Cycle { cycle_jobs } => Some(cycle_jobs),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(
+ cycle_errs.len(),
+ 1,
+ "expected exactly one cycle error: {errs:?}"
+ );
+ assert_eq!(cycle_errs[0], &vec!["a".to_string(), "b".to_string()]);
+ }
+
+ #[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 cycle_count = errs
+ .iter()
+ .filter(|e| matches!(e, ValidationError::Cycle { .. }))
+ .count();
+ assert_eq!(cycle_count, 2, "expected two cycle errors: {errs:?}");
+ }
+
+ #[test]
+ fn validate_rejects_empty_inputs() {
+ let jobs = vec![JobDef {
+ id: "setup".into(),
+ inputs: vec![],
+ }];
+ let errs = validate(&jobs).unwrap_err();
+ assert!(
+ errs.iter()
+ .any(|e| matches!(e, ValidationError::EmptyInputs { job_id } if job_id == "setup")),
+ "should report empty inputs for 'setup': {errs:?}"
+ );
+ }
+
+ #[test]
+ fn validate_rejects_unreachable_jobs() {
+ let jobs = vec![JobDef {
+ id: "orphan".into(),
+ inputs: vec!["orphan".into()],
+ }];
+ let errs = validate(&jobs).unwrap_err();
+ assert!(
+ errs.iter().any(
+ |e| matches!(e, ValidationError::Unreachable { job_id } if job_id == "orphan")
+ ),
+ "should report unreachable job 'orphan': {errs:?}"
+ );
+ }
+
+ #[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();
+ assert!(
+ errs.iter().any(
+ |e| matches!(e, ValidationError::ReservedSlash { job_id } if job_id == "foo/bar")
+ ),
+ "should report slash in job id: {errs:?}"
+ );
+ }
+}
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
new file mode 100644
index 0000000..a9882e9
--- /dev/null
+++ b/src/ci/mod.rs
@@ -0,0 +1,89 @@
+//! CI: trigger runs from push events, validate the job graph.
+
+pub mod graph;
+pub mod run;
+
+pub use graph::{EvalResult, JobDef, ValidationError, eval_ci, validate};
+pub use run::{Run, RunMeta, RunState, RunTimes, Runs};
+
+use crate::Result;
+use crate::event::{PushEvent, PushRef};
+use crate::fennel::Fennel;
+use crate::quire::Repo;
+
+/// Trigger CI for a push event: scan each updated ref for `.quire/ci.fnl`,
+/// create a run, and evaluate + validate it.
+pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
+ let repo = match quire.repo(&event.repo) {
+ Ok(r) if r.exists() => r,
+ Ok(_) => {
+ tracing::error!(repo = %event.repo, "repo not found on disk");
+ return;
+ }
+ Err(e) => {
+ tracing::error!(repo = %event.repo, %e, "invalid repo name in event");
+ return;
+ }
+ };
+
+ for push_ref in event.updated_refs() {
+ if let Err(e) = trigger_ref(&repo, event.pushed_at, push_ref) {
+ tracing::error!(
+ repo = %event.repo,
+ sha = %push_ref.new_sha,
+ %e,
+ "CI trigger failed"
+ );
+ }
+ }
+}
+
+/// Create and run CI for a single updated ref.
+///
+/// Returns `Ok(())` if CI ran (regardless of whether the run succeeded
+/// or failed), or `Err` if the trigger itself failed.
+fn trigger_ref(repo: &Repo, pushed_at: jiff::Timestamp, push_ref: &PushRef) -> Result<()> {
+ if !repo.has_ci_fnl(&push_ref.new_sha) {
+ return Ok(());
+ }
+
+ let meta = RunMeta {
+ sha: push_ref.new_sha.clone(),
+ r#ref: push_ref.r#ref.clone(),
+ pushed_at,
+ };
+
+ let mut run = repo.runs().create(&meta)?;
+
+ tracing::info!(
+ run_id = %run.id(),
+ sha = %push_ref.new_sha,
+ r#ref = %push_ref.r#ref,
+ "created CI run"
+ );
+
+ run.transition(RunState::Active)?;
+
+ let result = eval_and_validate(repo, &push_ref.new_sha);
+ match result {
+ Ok(()) => {
+ run.transition(RunState::Complete)?;
+ }
+ Err(e) => {
+ run.transition(RunState::Failed)?;
+ // Return the eval/validation error as the dispatch error.
+ Err(e)?;
+ }
+ }
+
+ Ok(())
+}
+
+/// Evaluate ci.fnl at a given SHA and validate the job graph.
+fn eval_and_validate(repo: &Repo, sha: &str) -> Result<()> {
+ let source = repo.ci_fnl_source(sha)?;
+ let fennel = Fennel::new()?;
+ let eval_result = eval_ci(&fennel, &source, &format!("{sha}:.quire/ci.fnl"))?;
+ validate(&eval_result.jobs)?;
+ Ok(())
+}
diff --git a/src/ci.rs b/src/ci/run.rs
similarity index 54%
rename from src/ci.rs
rename to src/ci/run.rs
index d077e74..6ae686d 100644
--- a/src/ci.rs
+++ b/src/ci/run.rs
@@ -1,8 +1,14 @@
+//! On-disk storage for CI runs.
+//!
+//! A run is a directory under `runs/<repo>/<state>/<id>/` containing
+//! `meta.yml` (immutable) and `times.yml` (timestamps). The directory's
+//! parent name is the authoritative state; transitions are atomic
+//! `rename` operations.
+
use std::path::{Path, PathBuf};
-use crate::event::{PushEvent, PushRef};
-use crate::fennel::{Fennel, FennelError};
-use crate::quire::Repo;
+use jiff::Timestamp;
+
use crate::{Error, Result};
/// The state of a CI run.
@@ -35,7 +41,7 @@ pub struct RunMeta {
/// The full ref name (e.g. `refs/heads/main`).
pub r#ref: String,
/// When the push occurred.
- pub pushed_at: jiff::Timestamp,
+ pub pushed_at: Timestamp,
}
/// Timestamps recorded across the run lifecycle. The directory name is the
@@ -44,10 +50,10 @@ pub struct RunMeta {
pub struct RunTimes {
/// When the run was picked up (moved to active).
#[serde(skip_serializing_if = "Option::is_none")]
- pub started_at: Option<jiff::Timestamp>,
+ pub started_at: Option<Timestamp>,
/// When the run finished (moved to complete/failed).
#[serde(skip_serializing_if = "Option::is_none")]
- pub finished_at: Option<jiff::Timestamp>,
+ pub finished_at: Option<Timestamp>,
}
/// Access to CI runs for a single repo.
@@ -245,7 +251,7 @@ impl Run {
self.state = to;
let mut times = self.read_times()?;
- let now = jiff::Timestamp::now();
+ let now = Timestamp::now();
match to {
RunState::Active if times.started_at.is_none() => times.started_at = Some(now),
RunState::Complete | RunState::Failed if times.finished_at.is_none() => {
@@ -273,252 +279,6 @@ impl Run {
}
}
-/// A registered job definition extracted from ci.fnl.
-pub struct JobDef {
- pub id: String,
- pub inputs: Vec<String>,
-}
-
-/// The result of evaluating a ci.fnl file.
-pub struct EvalResult {
- pub jobs: Vec<JobDef>,
-}
-
-/// 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> {
- fennel.eval_raw(source, name, |lua| {
- // Create a registration table. `job` will push into this.
- let registry: mlua::Table = lua.create_table()?;
- lua.globals().set("_quire_jobs", registry)?;
-
- // Define the `job` global: (job id inputs run-fn)
- let job_fn = lua.create_function(
- |lua, (id, inputs, run_fn): (mlua::String, mlua::Table, mlua::Function)| {
- let registry: mlua::Table = lua.globals().get("_quire_jobs")?;
- let entry = lua.create_table()?;
- entry.set("id", id)?;
- entry.set("inputs", inputs)?;
- entry.set("run", run_fn)?;
- registry.push(entry)?;
- Ok(())
- },
- )?;
- lua.globals().set("job", job_fn)?;
-
- Ok(())
- })?;
-
- // Extract the registration table.
- let lua_err = |e: mlua::Error| FennelError::from_lua(source, name, e);
- let registry: mlua::Table = fennel.lua().globals().get("_quire_jobs").map_err(lua_err)?;
- let mut jobs = Vec::new();
- for entry in registry.sequence_values::<mlua::Table>() {
- 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 mut inputs = Vec::new();
- for input in inputs_table.sequence_values::<String>() {
- inputs.push(input.map_err(lua_err)?);
- }
- jobs.push(JobDef { id, inputs });
- }
-
- Ok(EvalResult { jobs })
-}
-
-/// 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> },
-
- #[error(
- "Job '{job_id}' has empty inputs. Pass [:quire/push] (or another input) so it has something to fire it."
- )]
- EmptyInputs { job_id: String },
-
- #[error("Job '{job_id}' is not reachable from any source ref (e.g. :quire/push).")]
- Unreachable { job_id: String },
-
- #[error("Job id '{job_id}' contains '/', which is reserved for the 'quire/' source namespace.")]
- ReservedSlash { job_id: String },
-}
-
-/// 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>> {
- let mut errors = Vec::new();
-
- // Rule 4: no '/' in user job ids.
- for job in jobs {
- if job.id.contains('/') {
- errors.push(ValidationError::ReservedSlash {
- job_id: job.id.clone(),
- });
- }
- }
-
- // Rule 2: non-empty inputs.
- for job in jobs {
- if job.inputs.is_empty() {
- errors.push(ValidationError::EmptyInputs {
- job_id: job.id.clone(),
- });
- }
- }
-
- // Rule 1: acyclic.
- //
- // Build a directed graph where edges point from dependency to
- // dependent. Source refs (e.g. "quire/push") are not nodes.
- let mut graph: petgraph::Graph<&str, ()> = petgraph::Graph::new();
- let mut node_map: std::collections::HashMap<&str, petgraph::graph::NodeIndex> =
- std::collections::HashMap::new();
-
- for job in jobs {
- let idx = graph.add_node(job.id.as_str());
- node_map.insert(job.id.as_str(), idx);
- }
-
- for job in jobs {
- let dependent = node_map[job.id.as_str()];
- for input in &job.inputs {
- if let Some(&dependency) = node_map.get(input.as_str()) {
- graph.add_edge(dependency, dependent, ());
- }
- }
- }
-
- // Each non-trivial strongly connected component is a distinct cycle.
- // A single-node SCC is only a cycle if it has a self-edge.
- for scc in petgraph::algo::tarjan_scc(&graph) {
- let is_cycle = scc.len() > 1 || (scc.len() == 1 && graph.contains_edge(scc[0], scc[0]));
- if !is_cycle {
- continue;
- }
- let mut cycle_jobs: Vec<String> = scc.iter().map(|&idx| graph[idx].to_string()).collect();
- cycle_jobs.sort();
- errors.push(ValidationError::Cycle { cycle_jobs });
- }
-
- // Rule 3: reachability — every job's transitive inputs must include a source ref.
- let is_source = |name: &str| name.starts_with("quire/");
-
- for job in jobs {
- let mut visited = std::collections::HashSet::new();
- let mut stack: Vec<&str> = job.inputs.iter().map(|s| s.as_str()).collect();
- let mut found_source = false;
-
- while let Some(name) = stack.pop() {
- if !visited.insert(name) {
- continue;
- }
- if is_source(name) {
- found_source = true;
- break;
- }
- if let Some(upstream) = jobs.iter().find(|j| j.id == name) {
- for input in &upstream.inputs {
- stack.push(input.as_str());
- }
- }
- }
-
- if !found_source {
- errors.push(ValidationError::Unreachable {
- job_id: job.id.clone(),
- });
- }
- }
-
- if errors.is_empty() {
- Ok(())
- } else {
- Err(errors)
- }
-}
-
-/// Trigger CI for a push event: scan each updated ref for `.quire/ci.fnl`,
-/// create a run, and evaluate + validate it.
-pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
- let repo = match quire.repo(&event.repo) {
- Ok(r) if r.exists() => r,
- Ok(_) => {
- tracing::error!(repo = %event.repo, "repo not found on disk");
- return;
- }
- Err(e) => {
- tracing::error!(repo = %event.repo, %e, "invalid repo name in event");
- return;
- }
- };
-
- for push_ref in event.updated_refs() {
- if let Err(e) = trigger_ref(&repo, event.pushed_at, push_ref) {
- tracing::error!(
- repo = %event.repo,
- sha = %push_ref.new_sha,
- %e,
- "CI trigger failed"
- );
- }
- }
-}
-
-/// Create and run CI for a single updated ref.
-///
-/// Returns `Ok(())` if CI ran (regardless of whether the run succeeded
-/// or failed), or `Err` if the trigger itself failed.
-fn trigger_ref(repo: &Repo, pushed_at: jiff::Timestamp, push_ref: &PushRef) -> Result<()> {
- if !repo.has_ci_fnl(&push_ref.new_sha) {
- return Ok(());
- }
-
- let meta = RunMeta {
- sha: push_ref.new_sha.clone(),
- r#ref: push_ref.r#ref.clone(),
- pushed_at,
- };
-
- let mut run = repo.runs().create(&meta)?;
-
- tracing::info!(
- run_id = %run.id(),
- sha = %push_ref.new_sha,
- r#ref = %push_ref.r#ref,
- "created CI run"
- );
-
- run.transition(RunState::Active)?;
-
- let result = eval_and_validate(repo, &push_ref.new_sha);
- match result {
- Ok(()) => {
- run.transition(RunState::Complete)?;
- }
- Err(e) => {
- run.transition(RunState::Failed)?;
- // Return the eval/validation error as the dispatch error.
- Err(e)?;
- }
- }
-
- Ok(())
-}
-
-/// Evaluate ci.fnl at a given SHA and validate the job graph.
-fn eval_and_validate(repo: &Repo, sha: &str) -> Result<()> {
- let source = repo.ci_fnl_source(sha)?;
- let fennel = Fennel::new()?;
- let eval_result = eval_ci(&fennel, &source, &format!("{sha}:.quire/ci.fnl"))?;
- validate(&eval_result.jobs)?;
- Ok(())
-}
-
/// Write a serializable value to a YAML file atomically (temp file + rename).
fn write_yaml<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
let tmp_path = path.with_extension("yml.tmp");
@@ -654,7 +414,7 @@ mod tests {
run.transition(RunState::Active).expect("to active");
let active_times = run.read_times().expect("read state");
- let started = active_times.started_at.clone();
+ let started = active_times.started_at;
run.transition(RunState::Complete).expect("to complete");
let complete_times = run.read_times().expect("read state");
@@ -734,7 +494,7 @@ mod tests {
let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
let run = runs.create(&test_meta()).expect("create");
- let started: jiff::Timestamp = "2026-04-28T12:00:01Z".parse().expect("parse");
+ let started: Timestamp = "2026-04-28T12:00:01Z".parse().expect("parse");
run.write_times(&RunTimes {
started_at: Some(started),
finished_at: None,
@@ -748,185 +508,4 @@ mod tests {
let loaded_meta = run.read_meta().expect("read meta");
assert_eq!(loaded_meta, test_meta());
}
-
- // --- eval_ci tests ---
-
- fn fennel() -> Fennel {
- Fennel::new().expect("Fennel::new() should succeed")
- }
-
- #[test]
- fn eval_ci_registers_a_job() {
- let f = fennel();
- let source = r#"(job :test [:quire/push] (fn [_] nil))"#;
- let result = eval_ci(&f, source, "ci.fnl").expect("eval should succeed");
- assert_eq!(result.jobs.len(), 1);
- assert_eq!(result.jobs[0].id, "test");
- assert_eq!(result.jobs[0].inputs, vec!["quire/push"]);
- }
-
- #[test]
- fn eval_ci_registers_multiple_jobs() {
- let f = fennel();
- let source = r#"
-(job :build [:quire/push] (fn [_] nil))
-(job :test [:build] (fn [_] nil))
-"#;
- let result = eval_ci(&f, source, "ci.fnl").expect("eval should succeed");
- assert_eq!(result.jobs.len(), 2);
- assert_eq!(result.jobs[0].id, "build");
- assert_eq!(result.jobs[0].inputs, vec!["quire/push"]);
- assert_eq!(result.jobs[1].id, "test");
- assert_eq!(result.jobs[1].inputs, vec!["build"]);
- }
-
- #[test]
- fn eval_ci_errors_on_bad_fennel() {
- let f = fennel();
- let result = eval_ci(&f, "{:bad {:}", "ci.fnl");
- assert!(result.is_err(), "malformed Fennel should fail");
- }
-
- // --- validate 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());
- }
-
- #[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();
- 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:?}"
- );
- }
-
- #[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 cycle_errs: Vec<&Vec<String>> = errs
- .iter()
- .filter_map(|e| match e {
- ValidationError::Cycle { cycle_jobs } => Some(cycle_jobs),
- _ => None,
- })
- .collect();
- assert_eq!(
- cycle_errs.len(),
- 1,
- "expected exactly one cycle error: {errs:?}"
- );
- assert_eq!(cycle_errs[0], &vec!["a".to_string(), "b".to_string()]);
- }
-
- #[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 cycle_count = errs
- .iter()
- .filter(|e| matches!(e, ValidationError::Cycle { .. }))
- .count();
- assert_eq!(cycle_count, 2, "expected two cycle errors: {errs:?}");
- }
-
- #[test]
- fn validate_rejects_empty_inputs() {
- let jobs = vec![JobDef {
- id: "setup".into(),
- inputs: vec![],
- }];
- let errs = validate(&jobs).unwrap_err();
- assert!(
- errs.iter()
- .any(|e| matches!(e, ValidationError::EmptyInputs { job_id } if job_id == "setup")),
- "should report empty inputs for 'setup': {errs:?}"
- );
- }
-
- #[test]
- fn validate_rejects_unreachable_jobs() {
- let jobs = vec![JobDef {
- id: "orphan".into(),
- inputs: vec!["orphan".into()],
- }];
- let errs = validate(&jobs).unwrap_err();
- assert!(
- errs.iter().any(
- |e| matches!(e, ValidationError::Unreachable { job_id } if job_id == "orphan")
- ),
- "should report unreachable job 'orphan': {errs:?}"
- );
- }
-
- #[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();
- assert!(
- errs.iter().any(
- |e| matches!(e, ValidationError::ReservedSlash { job_id } if job_id == "foo/bar")
- ),
- "should report slash in job id: {errs:?}"
- );
- }
}