Add config loading to quire-ci serve
Port the same config setup from quire-server: a QuireCi struct with
base_dir, config.fnl loading via Fennel, and a GlobalConfig with
optional sentry and port (default 3000). The serve command reads
port from config and initializes Sentry + tracing with the same
telemetry pipeline. Added --base-dir flag with QQUIRE_CI_BASE_DIR
env alias.

Assisted-by: Owl Alpha via pi
change quqrxrvkvkrvrouwptmuxxwpoysulokn
commit 4591d33107056351b3eba63d3adf916e0391288d
author Alpha Chen <alpha@kejadlen.dev>
date
parent xuumstlr
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 7d5ed03..1839535 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -1,3 +1,4 @@
+mod quire;
 mod server;
 mod sink;
 
@@ -46,6 +47,10 @@ const VERSION: &str = env!("QUIRE_VERSION");
 /// Run and validate quire CI pipelines.
 #[derive(Facet)]
 struct Cli {
+    /// Root directory for quire-ci data (default: /var/quire-ci).
+    #[facet(args::named, args::env_alias = "QUIRE_CI_BASE_DIR")]
+    base_dir: Option<PathBuf>,
+
     /// Workspace root containing .quire/ci.fnl. Defaults to cwd.
     #[facet(args::named, args::short = 'w', default = ".")]
     workspace: PathBuf,
@@ -123,11 +128,7 @@ enum Commands {
     },
 
     /// Start the HTTP server.
-    Serve {
-        /// Port to listen on.
-        #[facet(args::named, default = 3000)]
-        port: u16,
-    },
+    Serve,
 }
 
 /// RAII wrapper around a tempdir holding captured sh logs. On drop,
@@ -296,12 +297,16 @@ fn main() -> Result<()> {
 
     match cli.command {
         Commands::Validate => validate(workspace),
-        Commands::Serve { port } => {
+        Commands::Serve => {
+            let quire = match cli.base_dir {
+                Some(ref dir) => quire::QuireCi::new(dir.clone()),
+                None => quire::QuireCi::default(),
+            };
             let rt = tokio::runtime::Builder::new_multi_thread()
                 .enable_all()
                 .build()
                 .into_diagnostic()?;
-            rt.block_on(server::run(port))
+            rt.block_on(server::run(quire))
         }
         Commands::Run {
             events,
diff --git a/quire-ci/src/quire.rs b/quire-ci/src/quire.rs
new file mode 100644
index 0000000..c98d9d9
--- /dev/null
+++ b/quire-ci/src/quire.rs
@@ -0,0 +1,131 @@
+use std::path::{Path, PathBuf};
+
+use miette::{Result, ensure};
+
+use quire_core::fennel::Fennel;
+
+/// Parsed global configuration (`<base-dir>/config.fnl`).
+#[derive(serde::Deserialize, Debug)]
+#[serde(rename_all = "kebab-case")]
+pub struct GlobalConfig {
+    #[serde(default)]
+    pub sentry: Option<SentryConfig>,
+    /// TCP port the HTTP server binds to on all interfaces (`0.0.0.0`).
+    #[serde(default = "default_port")]
+    pub port: u16,
+}
+
+fn default_port() -> u16 {
+    3000
+}
+
+#[derive(serde::Deserialize, Debug)]
+pub struct SentryConfig {
+    pub dsn: quire_core::secret::SecretString,
+}
+
+/// Application runtime context.
+///
+/// Carries configuration and provides resolved paths.
+#[derive(Clone)]
+pub struct QuireCi {
+    base_dir: PathBuf,
+}
+
+impl Default for QuireCi {
+    fn default() -> Self {
+        Self::new(PathBuf::from("/var/quire-ci"))
+    }
+}
+
+impl QuireCi {
+    pub fn new(base_dir: PathBuf) -> Self {
+        Self { base_dir }
+    }
+
+    #[allow(dead_code)]
+    pub fn base_dir(&self) -> &Path {
+        &self.base_dir
+    }
+
+    pub fn config_path(&self) -> PathBuf {
+        self.base_dir.join("config.fnl")
+    }
+
+    /// Load and parse the global Fennel config file.
+    pub fn global_config(&self) -> Result<GlobalConfig> {
+        let config_path = self.config_path();
+        ensure!(
+            config_path.exists(),
+            "config not found: {}",
+            config_path.display()
+        );
+        let fennel = Fennel::new()?;
+        Ok(fennel.load_file(&config_path)?)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn quire() -> QuireCi {
+        QuireCi::default()
+    }
+
+    #[test]
+    fn default_paths() {
+        let q = quire();
+        assert_eq!(q.base_dir(), Path::new("/var/quire-ci"));
+        assert_eq!(q.config_path(), PathBuf::from("/var/quire-ci/config.fnl"));
+    }
+
+    #[test]
+    fn global_config_defaults() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let config_path = dir.path().join("config.fnl");
+        fs_err::write(&config_path, "{}").expect("write");
+
+        let q = QuireCi::new(dir.path().to_path_buf());
+        let config = q.global_config().expect("global_config should load");
+        assert_eq!(config.port, 3000);
+    }
+
+    #[test]
+    fn global_config_parses_custom_port() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let config_path = dir.path().join("config.fnl");
+        fs_err::write(&config_path, r#"{:port 4000}"#).expect("write");
+
+        let q = QuireCi::new(dir.path().to_path_buf());
+        let config = q.global_config().expect("global_config should load");
+        assert_eq!(config.port, 4000);
+    }
+
+    #[test]
+    fn global_config_missing_file_errors() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let q = QuireCi::new(dir.path().to_path_buf());
+        let err = q.global_config().unwrap_err();
+        assert!(
+            err.to_string().contains("config not found"),
+            "expected config not found error, got {err:?}"
+        );
+    }
+
+    #[test]
+    fn global_config_loads_sentry() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let config_path = dir.path().join("config.fnl");
+        fs_err::write(
+            &config_path,
+            r#"{:sentry {:dsn "https://key@sentry.io/123"}}"#,
+        )
+        .expect("write");
+
+        let q = QuireCi::new(dir.path().to_path_buf());
+        let config = q.global_config().expect("global_config should load");
+        let sentry = config.sentry.expect("sentry should be present");
+        assert_eq!(sentry.dsn.reveal().unwrap(), "https://key@sentry.io/123");
+    }
+}
diff --git a/quire-ci/src/server.rs b/quire-ci/src/server.rs
index 9ac7a0d..88bcfa8 100644
--- a/quire-ci/src/server.rs
+++ b/quire-ci/src/server.rs
@@ -3,9 +3,10 @@ use std::net::SocketAddr;
 use axum::Router;
 use axum::routing::get;
 use miette::{IntoDiagnostic, Result};
-use tracing_subscriber::EnvFilter;
-use tracing_subscriber::prelude::__tracing_subscriber_SubscriberExt;
-use tracing_subscriber::util::SubscriberInitExt;
+use quire_core::telemetry::{self, FmtMode};
+use sentry::ClientInitGuard;
+
+use crate::quire::QuireCi;
 
 const VERSION: &str = env!("QUIRE_VERSION");
 
@@ -17,18 +18,44 @@ async fn index() -> String {
     format!("quire-ci {VERSION}\n")
 }
 
-pub async fn run(port: u16) -> Result<()> {
-    let filter = EnvFilter::builder()
-        .with_env_var("QUIRE_LOG")
-        .from_env()
-        .into_diagnostic()?;
+/// Initialize Sentry if the global config provides a DSN.
+fn init_sentry(quire: &QuireCi) -> Option<ClientInitGuard> {
+    let config = match quire.global_config() {
+        Ok(config) => config,
+        Err(e) => {
+            tracing::warn!(
+                error = %e,
+                "failed to load global config, skipping Sentry init"
+            );
+            return None;
+        }
+    };
+
+    let sentry_config = config.sentry.as_ref()?;
+    let dsn = match sentry_config.dsn.reveal() {
+        Ok(dsn) => dsn,
+        Err(e) => {
+            tracing::warn!(
+                error = &e as &(dyn std::error::Error + 'static),
+                "failed to resolve Sentry DSN, skipping Sentry init"
+            );
+            return None;
+        }
+    };
 
-    let fmt_layer = tracing_subscriber::fmt::layer().with_writer(std::io::stderr);
+    Some(sentry::init((
+        dsn,
+        telemetry::sentry_client_options(VERSION),
+    )))
+}
+
+pub async fn run(quire: QuireCi) -> Result<()> {
+    let config = quire.global_config().ok();
+    let port = config.as_ref().map(|c| c.port).unwrap_or(3000);
 
-    tracing_subscriber::registry()
-        .with(fmt_layer)
-        .with(filter)
-        .init();
+    let _sentry = init_sentry(&quire);
+    let miette_layer = telemetry::MietteLayer::new();
+    let _tracing_guard = telemetry::init_tracing(miette_layer, FmtMode::AutoJson)?;
 
     let app = Router::new()
         .route("/health", get(health))
@@ -43,7 +70,5 @@ pub async fn run(port: u16) -> Result<()> {
 
     axum::serve(listener, app).await.into_diagnostic()?;
 
-    tracing::info!(version = %VERSION, "server shutdown complete");
-
     Ok(())
 }