Add quire-ci binary that compiles and prints the pipeline
Reads .quire/ci.fnl, compiles it via quire-core, and prints the
job graph in topological order. Miette renders compile errors
with inline source labels.

Assisted-by: GLM-5.1 via pi
change zknokwummpwwutxyntxmpqppotprvqnw
commit 01d160fb6c1a380159de9a3460d6e5b431922178
author Alpha Chen <alpha@kejadlen.dev>
date
parent toqquzyt
diff --git a/Cargo.lock b/Cargo.lock
index e846408..837024c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2127,6 +2127,8 @@ dependencies = [
 name = "quire-ci"
 version = "0.1.0"
 dependencies = [
+ "fs-err",
+ "miette",
  "quire-core",
 ]
 
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index 5257aa8..e28c06d 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -4,4 +4,6 @@ version = "0.1.0"
 edition = "2024"
 
 [dependencies]
+fs-err = "*"
+miette = { version = "*", features = ["fancy"] }
 quire-core = { path = "../quire-core" }
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index f328e4d..2d8a313 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -1 +1,68 @@
-fn main() {}
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use miette::IntoDiagnostic;
+use quire_core::ci::pipeline::CompileError;
+
+fn main() -> ExitCode {
+    miette::set_panic_hook();
+    match run() {
+        Ok(()) => ExitCode::SUCCESS,
+        Err(e) => {
+            eprintln!("{e:?}");
+            ExitCode::FAILURE
+        }
+    }
+}
+
+fn run() -> miette::Result<()> {
+    let path = find_ci_fnl()?;
+
+    let source = fs_err::read_to_string(&path).into_diagnostic()?;
+
+    let pipeline = match quire_core::ci::pipeline::compile(&source, &path.display().to_string()) {
+        Ok(p) => p,
+        Err(CompileError::Fennel(err)) => {
+            return Err(miette::Report::new_boxed(err));
+        }
+        Err(CompileError::Pipeline(err)) => {
+            return Err(miette::Report::new_boxed(err));
+        }
+    };
+
+    let jobs = pipeline.jobs();
+    if jobs.is_empty() {
+        println!("No jobs registered.");
+        return Ok(());
+    }
+
+    if let Some(image) = pipeline.image() {
+        println!("Image: {image}");
+    }
+
+    let topo = pipeline.topo_order();
+    println!("Jobs (topological order):");
+    for id in &topo {
+        let job = pipeline.job(id).expect("topo_order returns valid ids");
+        let inputs = job.inputs.join(", ");
+        println!("  {id} <- [{inputs}]");
+    }
+
+    println!("\nAll validations passed.");
+    Ok(())
+}
+
+/// Walk from cwd upward to find `.quire/ci.fnl`.
+fn find_ci_fnl() -> miette::Result<PathBuf> {
+    let cwd = std::env::current_dir().into_diagnostic()?;
+    let mut dir = cwd.as_path();
+    loop {
+        let candidate = dir.join(".quire").join("ci.fnl");
+        if candidate.is_file() {
+            return Ok(candidate);
+        }
+        dir = dir
+            .parent()
+            .ok_or_else(|| miette::miette!("no .quire/ci.fnl found in any parent directory"))?;
+    }
+}