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
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(())