Add clap to quire-ci with validate subcommand
Promote clap to a workspace dependency and consolidate
quire-server onto it. quire-ci gets a `quire validate` subcommand
that compiles and validates a ci.fnl pipeline.

Assisted-by: GLM-5.1 via pi
change xqquuuyyquowltvrwpkwovysqynumsrx
commit 706bd3f8b54b3893b468b466f7ff3390fdfa9493
author Alpha Chen <alpha@kejadlen.dev>
date
parent ovywtzkw
diff --git a/Cargo.lock b/Cargo.lock
index 837024c..3facc65 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2127,6 +2127,7 @@ dependencies = [
 name = "quire-ci"
 version = "0.1.0"
 dependencies = [
+ "clap",
  "fs-err",
  "miette",
  "quire-core",
diff --git a/Cargo.toml b/Cargo.toml
index d35d962..ba1c458 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,6 +3,7 @@ members = ["quire-ci", "quire-core", "quire-server"]
 resolver = "3"
 
 [workspace.dependencies]
+clap = { version = "*", features = ["derive", "env"] }
 fs-err = "*"
 jiff = { version = "*", features = ["serde"] }
 miette = "*"
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index 0b1e2a8..a1970c5 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -4,6 +4,7 @@ version = "0.1.0"
 edition = "2024"
 
 [dependencies]
+clap = { workspace = true }
 fs-err = { workspace = true }
 miette = { workspace = true, features = ["fancy"] }
 quire-core = { path = "../quire-core" }
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 2d8a313..2f9744d 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -1,34 +1,39 @@
 use std::path::PathBuf;
-use std::process::ExitCode;
 
+use clap::Parser;
 use miette::IntoDiagnostic;
-use quire_core::ci::pipeline::CompileError;
 
-fn main() -> ExitCode {
+/// Validate a quire CI pipeline.
+#[derive(Parser)]
+#[command(version, propagate_version = true)]
+struct Cli {
+    #[command(subcommand)]
+    command: Commands,
+}
+
+#[derive(clap::Subcommand)]
+enum Commands {
+    /// Compile and validate a ci.fnl pipeline.
+    Validate {
+        /// Workspace root containing .quire/ci.fnl. Defaults to cwd.
+        #[arg(short, long, default_value = ".")]
+        workspace: PathBuf,
+    },
+}
+
+fn main() -> miette::Result<()> {
     miette::set_panic_hook();
-    match run() {
-        Ok(()) => ExitCode::SUCCESS,
-        Err(e) => {
-            eprintln!("{e:?}");
-            ExitCode::FAILURE
-        }
-    }
+    run()
 }
 
 fn run() -> miette::Result<()> {
-    let path = find_ci_fnl()?;
+    let cli = Cli::parse();
+    let Commands::Validate { workspace } = cli.command;
 
+    let path = workspace.join(".quire").join("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 pipeline = quire_core::ci::pipeline::compile(&source, &path.display().to_string())?;
 
     let jobs = pipeline.jobs();
     if jobs.is_empty() {
@@ -51,18 +56,3 @@ fn run() -> miette::Result<()> {
     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"))?;
-    }
-}
diff --git a/quire-server/Cargo.toml b/quire-server/Cargo.toml
index 50ad291..49709e6 100644
--- a/quire-server/Cargo.toml
+++ b/quire-server/Cargo.toml
@@ -32,7 +32,7 @@ tracing = { workspace = true }
 
 askama = "*"
 axum = "*"
-clap = { version = "*", features = ["derive", "env"] }
+clap = { workspace = true }
 clap_complete = "*"
 rusqlite = { version = "*", features = ["bundled"] }
 rusqlite_migration = "*"