Add `quire ci validate` subcommand
Exposes the ci.fnl job graph evaluation and structural validation
as a CLI command so operators can check config files without pushing.
The `ci` command group is structured for future subcommands (run, etc).

Task: rl (Expose ci.fnl validation as a subcommand)
Assisted-by: GLM-5.1 via pi
change wlstttuvvspvmwuqtmyunupxqzovllxq
commit aa56548869908237ef5511c8a6c70b5681cd7bea
author Alpha Chen <alpha@kejadlen.dev>
date
parent rtzsqvtu
diff --git a/src/bin/quire/commands/ci.rs b/src/bin/quire/commands/ci.rs
new file mode 100644
index 0000000..2d762fb
--- /dev/null
+++ b/src/bin/quire/commands/ci.rs
@@ -0,0 +1,57 @@
+use miette::{IntoDiagnostic, Result};
+
+use quire::ci::{ValidationError, eval_ci};
+use quire::fennel::Fennel;
+
+/// Validate a ci.fnl file without executing any jobs.
+///
+/// Evaluates the Fennel source to extract the job registration table,
+/// then runs the four structural validations. Prints each job found
+/// and any validation errors.
+pub async fn validate(path: &std::path::Path) -> Result<()> {
+    let source = fs_err::read_to_string(path).into_diagnostic()?;
+    let name = path.display().to_string();
+
+    let fennel = Fennel::new()?;
+    let result = eval_ci(&fennel, &source, &name)?;
+
+    if result.jobs.is_empty() {
+        println!("No jobs registered.");
+        return Ok(());
+    }
+
+    println!("Jobs:");
+    for job in &result.jobs {
+        let inputs = job.inputs.join(", ");
+        println!("  {} ← [{}]", job.id, inputs);
+    }
+
+    match quire::ci::validate(&result.jobs) {
+        Ok(()) => {
+            println!("\nAll validations passed.");
+        }
+        Err(errors) => {
+            println!("\nValidation errors:");
+            for err in &errors {
+                let label = match err {
+                    ValidationError::Cycle { cycle_jobs } => {
+                        format!("cycle: {}", cycle_jobs.join(" → "))
+                    }
+                    ValidationError::EmptyInputs { job_id } => {
+                        format!("{job_id}: empty inputs")
+                    }
+                    ValidationError::Unreachable { job_id } => {
+                        format!("{job_id}: unreachable from any source ref")
+                    }
+                    ValidationError::ReservedSlash { job_id } => {
+                        format!("{job_id}: '/' in job id")
+                    }
+                };
+                println!("  ✗ {label}");
+            }
+            std::process::exit(1);
+        }
+    }
+
+    Ok(())
+}
diff --git a/src/bin/quire/commands/mod.rs b/src/bin/quire/commands/mod.rs
index 59b49fd..8f0e377 100644
--- a/src/bin/quire/commands/mod.rs
+++ b/src/bin/quire/commands/mod.rs
@@ -1,3 +1,4 @@
+pub mod ci;
 pub mod exec;
 pub mod hook;
 pub mod repo;
diff --git a/src/bin/quire/main.rs b/src/bin/quire/main.rs
index 3fb4ff7..7c32837 100644
--- a/src/bin/quire/main.rs
+++ b/src/bin/quire/main.rs
@@ -50,6 +50,12 @@ enum Commands {
         #[command(subcommand)]
         command: RepoCommands,
     },
+
+    /// CI pipeline operations.
+    Ci {
+        #[command(subcommand)]
+        command: CiCommands,
+    },
 }
 
 #[derive(Subcommand)]
@@ -70,6 +76,15 @@ enum RepoCommands {
     },
 }
 
+#[derive(Subcommand)]
+enum CiCommands {
+    /// Validate a ci.fnl file without running any jobs.
+    Validate {
+        /// Path to the ci.fnl file to validate.
+        path: std::path::PathBuf,
+    },
+}
+
 /// Initialize Sentry if the global config provides a DSN.
 ///
 /// Returns the guard if initialized, or None if Sentry is not configured.
@@ -135,6 +150,9 @@ async fn main() -> Result<()> {
             RepoCommands::List => commands::repo::list(&quire).await?,
             RepoCommands::Rm { name } => commands::repo::rm(&quire, &name).await?,
         },
+        Commands::Ci { command } => match command {
+            CiCommands::Validate { path } => commands::ci::validate(&path).await?,
+        },
     }
 
     Ok(())