From 27ac9a7302629f3f10bf3eacfefb8e0372a2b730 Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 13 Sep 2025 11:30:57 -0500 Subject: [PATCH] feat: add formatter CLI argument, setup asset embedding in release mode --- .gitignore | 5 +- Cargo.lock | 209 ++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 6 +- src/config/mod.rs | 6 +- src/formatter.rs | 4 +- src/main.rs | 54 +++++++++--- src/web/assets.rs | 64 ++++++++++++++ src/web/mod.rs | 1 + src/web/routes.rs | 88 +++++++++++++++---- 9 files changed, 396 insertions(+), 41 deletions(-) create mode 100644 src/web/assets.rs diff --git a/.gitignore b/.gitignore index dfc9db2..a79e4aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .env /target /go/ -.cargo/config.toml \ No newline at end of file +.cargo/config.toml + +**/*.md +!/README.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 23ea398..a294b8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + [[package]] name = "anyhow" version = "1.0.99" @@ -175,6 +225,7 @@ dependencies = [ "axum", "bitflags 2.9.4", "chrono", + "clap", "compile-time", "cookie", "dashmap 6.1.0", @@ -184,6 +235,7 @@ dependencies = [ "futures", "governor", "http 1.3.1", + "mime_guess", "num-format", "once_cell", "poise", @@ -191,6 +243,7 @@ dependencies = [ "regex", "reqwest 0.12.23", "reqwest-middleware", + "rust-embed", "serde", "serde_json", "serde_path_to_error", @@ -200,6 +253,7 @@ dependencies = [ "time", "tl", "tokio", + "tower-http", "tracing", "tracing-subscriber", "url", @@ -247,6 +301,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -337,6 +401,52 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "clap" +version = "4.5.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "command_attr" version = "0.5.3" @@ -571,9 +681,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" dependencies = [ "powerfmt", "serde", @@ -947,6 +1057,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "governor" version = "0.10.1" @@ -1129,6 +1252,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -1440,6 +1569,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itoa" version = "1.0.15" @@ -1730,6 +1865,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + [[package]] name = "openssl" version = "0.10.73" @@ -2259,6 +2400,41 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.106", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" +dependencies = [ + "globset", + "sha2", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -3097,12 +3273,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde", @@ -3112,15 +3287,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -3334,14 +3509,24 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ "bitflags 2.9.4", "bytes", + "futures-core", "futures-util", "http 1.3.1", "http-body 1.0.1", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3577,6 +3762,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uwl" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 0141ee3..76377ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ sqlx = { version = "0.8.6", features = [ "macros", ] } thiserror = "2.0.16" -time = "0.3.41" +time = "0.3.43" tokio = { version = "1.47.1", features = ["full"] } tl = "0.7.8" tracing = "0.1.41" @@ -44,5 +44,9 @@ governor = "0.10.1" once_cell = "1.21.3" serde_path_to_error = "0.1.17" num-format = "0.4.4" +tower-http = { version = "0.6.0", features = ["fs", "cors", "trace"] } +rust-embed = { version = "8.0", features = ["debug-embed", "include-exclude"] } +mime_guess = "2.0" +clap = { version = "4.5", features = ["derive"] } [dev-dependencies] diff --git a/src/config/mod.rs b/src/config/mod.rs index e1d5bba..f29c8ce 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -20,7 +20,7 @@ pub struct Config { /// Defaults to "info" if not specified #[serde(default = "default_log_level")] pub log_level: String, - /// Port for the web server + /// Port for the web server (default: 8080) #[serde(default = "default_port")] pub port: u16, /// Database connection URL @@ -51,9 +51,9 @@ fn default_log_level() -> String { "info".to_string() } -/// Default port of 3000 +/// Default port of 8080 fn default_port() -> u16 { - 3000 + 8080 } /// Default shutdown timeout of 8 seconds diff --git a/src/formatter.rs b/src/formatter.rs index b37e6b9..39e7d6f 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -24,14 +24,14 @@ const TIMESTAMP_FORMAT: &[FormatItem<'static>] = /// A custom formatter with enhanced timestamp formatting /// /// Re-implementation of the Full formatter with improved timestamp display. -pub struct CustomFormatter; +pub struct CustomPrettyFormatter; /// A custom JSON formatter that flattens fields to root level /// /// Outputs logs in the format: { "message": "...", "level": "...", "customAttribute": "..." } pub struct CustomJsonFormatter; -impl FormatEvent for CustomFormatter +impl FormatEvent for CustomPrettyFormatter where S: Subscriber + for<'a> LookupSpan<'a>, N: for<'a> FormatFields<'a> + 'static, diff --git a/src/main.rs b/src/main.rs index ba50809..ed72a7c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +use clap::Parser; use figment::value::UncasedStr; use num_format::{Locale, ToFormattedString}; use serenity::all::{ActivityData, ClientBuilder, Context, GatewayIntents}; @@ -28,6 +29,25 @@ mod services; mod state; mod web; +/// Banner Discord Bot - Course availability monitoring +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Log formatter to use + #[arg(long, value_enum, default_value_t = LogFormatter::Auto)] + formatter: LogFormatter, +} + +#[derive(clap::ValueEnum, Clone, Debug)] +enum LogFormatter { + /// Use pretty formatter (default in debug mode) + Pretty, + /// Use JSON formatter (default in release mode) + Json, + /// Auto-select based on build mode (debug=pretty, release=json) + Auto, +} + async fn update_bot_status(ctx: &Context, app_state: &AppState) -> Result<(), anyhow::Error> { let course_count = app_state.get_course_count().await?; @@ -44,6 +64,9 @@ async fn update_bot_status(ctx: &Context, app_state: &AppState) -> Result<(), an async fn main() { dotenvy::dotenv().ok(); + // Parse CLI arguments + let args = Args::parse(); + // Load configuration first to get log level let config: Config = Figment::new() .merge(Env::raw().map(|k| { @@ -64,22 +87,31 @@ async fn main() { base_level )) }); - let subscriber = { - #[cfg(debug_assertions)] - { + + // Select formatter based on CLI args + let use_pretty = match args.formatter { + LogFormatter::Pretty => true, + LogFormatter::Json => false, + LogFormatter::Auto => cfg!(debug_assertions), + }; + + let subscriber: Box = if use_pretty { + Box::new( FmtSubscriber::builder() .with_target(true) - .event_format(formatter::CustomFormatter) - } - #[cfg(not(debug_assertions))] - { + .event_format(formatter::CustomPrettyFormatter) + .with_env_filter(filter) + .finish(), + ) + } else { + Box::new( FmtSubscriber::builder() .with_target(true) .event_format(formatter::CustomJsonFormatter) - } - } - .with_env_filter(filter) - .finish(); + .with_env_filter(filter) + .finish(), + ) + }; tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); // Log application startup context diff --git a/src/web/assets.rs b/src/web/assets.rs new file mode 100644 index 0000000..f0674c8 --- /dev/null +++ b/src/web/assets.rs @@ -0,0 +1,64 @@ +//! Embedded assets for the web frontend +//! +//! This module handles serving static assets that are embedded into the binary +//! at compile time using rust-embed. + +use axum::{ + extract::Path, + http::{StatusCode, header}, + response::{Html, IntoResponse, Response}, +}; +use rust_embed::RustEmbed; + +/// Embedded web assets from the dist directory +#[derive(RustEmbed)] +#[folder = "web/dist/"] +#[include = "*"] +#[exclude = "*.map"] +pub struct WebAssets; + +/// Serve embedded static assets +pub async fn serve_asset(Path(path): Path) -> Response { + let path = path.trim_start_matches('/'); + + match WebAssets::get(path) { + Some(content) => { + let mime_type = mime_guess::from_path(path).first_or_text_plain(); + let data = content.data.to_vec(); + ([(header::CONTENT_TYPE, mime_type.as_ref())], data).into_response() + } + None => (StatusCode::NOT_FOUND, "Asset not found").into_response(), + } +} + +/// Serve the main SPA index.html for client-side routing +pub async fn serve_spa_index() -> Response { + match WebAssets::get("index.html") { + Some(content) => { + let data = content.data.to_vec(); + let html_content = String::from_utf8_lossy(&data).to_string(); + Html(html_content).into_response() + } + None => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load index.html", + ) + .into_response(), + } +} + +const ASSET_EXTENSIONS: &[&str] = &[ + "js", "css", "png", "jpg", "jpeg", "gif", "svg", "ico", "woff", "woff2", "ttf", "eot", +]; + +/// Check if a path should be served as a static asset +pub fn is_asset_path(path: &str) -> bool { + if !path.starts_with("/assets/") { + return path.eq("index.html"); + } + + match path.split_once('.') { + Some((_, extension)) => ASSET_EXTENSIONS.contains(&extension), + None => false, + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 2f27b41..3ab95f0 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,5 +1,6 @@ //! Web API module for the banner application. +pub mod assets; pub mod routes; pub use routes::*; diff --git a/src/web/routes.rs b/src/web/routes.rs index 05c2f43..e4a427b 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -1,10 +1,22 @@ //! Web API endpoints for Banner bot monitoring and metrics. -use axum::{Router, extract::State, response::Json, routing::get}; +use axum::{ + Router, + extract::State, + http::{StatusCode, Uri}, + response::{IntoResponse, Json, Response}, + routing::{any, get}, +}; use serde_json::{Value, json}; use std::sync::Arc; +use tower_http::{ + cors::{Any, CorsLayer}, + trace::TraceLayer, +}; use tracing::info; +use crate::web::assets::{is_asset_path, serve_asset, serve_spa_index}; + use crate::banner::BannerApi; /// Shared application state for web server @@ -15,24 +27,72 @@ pub struct BannerState { /// Creates the web server router pub fn create_router(state: BannerState) -> Router { - Router::new() - .route("/", get(root)) + let api_router = Router::new() .route("/health", get(health)) .route("/status", get(status)) .route("/metrics", get(metrics)) - .with_state(state) + .with_state(state); + + if cfg!(debug_assertions) { + // Development mode: API routes only, frontend served by Vite dev server + Router::new() + .route("/", get(root)) + .nest("/api", api_router) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ) + .layer(TraceLayer::new_for_http()) + } else { + // Production mode: serve embedded assets and handle SPA routing + Router::new() + .route("/", get(root)) + .nest("/api", api_router) + .route("/assets/{*path}", any(serve_asset)) + .fallback(handle_spa_fallback) + .layer(TraceLayer::new_for_http()) + } } -async fn root() -> Json { - Json(json!({ - "message": "Banner Discord Bot API", - "version": "0.1.0", - "endpoints": { - "health": "/health", - "status": "/status", - "metrics": "/metrics" - } - })) +async fn root() -> Response { + if cfg!(debug_assertions) { + // Development mode: return API info + Json(json!({ + "message": "Banner Discord Bot API", + "version": "0.2.1", + "mode": "development", + "frontend": "http://localhost:3000", + "endpoints": { + "health": "/api/health", + "status": "/api/status", + "metrics": "/api/metrics" + } + })) + .into_response() + } else { + // Production mode: serve the SPA index.html + handle_spa_fallback(Uri::from_static("/")).await + } +} + +/// Handles SPA routing by serving index.html for non-API, non-asset requests +async fn handle_spa_fallback(uri: Uri) -> Response { + let path = uri.path(); + + // Don't serve index.html for API routes or asset requests + if path.starts_with("/api/") || is_asset_path(path) { + return (StatusCode::NOT_FOUND, "Not Found").into_response(); + } + + // In production, serve embedded index.html for SPA routing + if cfg!(not(debug_assertions)) { + return serve_spa_index().await; + } + + // Development fallback (shouldn't reach here in production) + (StatusCode::NOT_FOUND, "Not Found").into_response() } /// Health check endpoint