feat: add formatter CLI argument, setup asset embedding in release mode

This commit is contained in:
2025-09-13 11:30:57 -05:00
parent 1d345ed247
commit 27ac9a7302
9 changed files with 396 additions and 41 deletions

3
.gitignore vendored
View File

@@ -2,3 +2,6 @@
/target /target
/go/ /go/
.cargo/config.toml .cargo/config.toml
**/*.md
!/README.md

209
Cargo.lock generated
View File

@@ -41,6 +41,56 @@ dependencies = [
"libc", "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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.99" version = "1.0.99"
@@ -175,6 +225,7 @@ dependencies = [
"axum", "axum",
"bitflags 2.9.4", "bitflags 2.9.4",
"chrono", "chrono",
"clap",
"compile-time", "compile-time",
"cookie", "cookie",
"dashmap 6.1.0", "dashmap 6.1.0",
@@ -184,6 +235,7 @@ dependencies = [
"futures", "futures",
"governor", "governor",
"http 1.3.1", "http 1.3.1",
"mime_guess",
"num-format", "num-format",
"once_cell", "once_cell",
"poise", "poise",
@@ -191,6 +243,7 @@ dependencies = [
"regex", "regex",
"reqwest 0.12.23", "reqwest 0.12.23",
"reqwest-middleware", "reqwest-middleware",
"rust-embed",
"serde", "serde",
"serde_json", "serde_json",
"serde_path_to_error", "serde_path_to_error",
@@ -200,6 +253,7 @@ dependencies = [
"time", "time",
"tl", "tl",
"tokio", "tokio",
"tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
@@ -247,6 +301,16 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.0" version = "3.19.0"
@@ -337,6 +401,52 @@ dependencies = [
"windows-link 0.2.0", "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]] [[package]]
name = "command_attr" name = "command_attr"
version = "0.5.3" version = "0.5.3"
@@ -571,9 +681,9 @@ dependencies = [
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.4.0" version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc"
dependencies = [ dependencies = [
"powerfmt", "powerfmt",
"serde", "serde",
@@ -947,6 +1057,19 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" 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]] [[package]]
name = "governor" name = "governor"
version = "0.10.1" version = "0.10.1"
@@ -1129,6 +1252,12 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -1440,6 +1569,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.15" version = "1.0.15"
@@ -1730,6 +1865,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.73" version = "0.10.73"
@@ -2259,6 +2400,41 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.26" version = "0.1.26"
@@ -3097,12 +3273,11 @@ dependencies = [
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.41" version = "0.3.43"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa",
"num-conv", "num-conv",
"powerfmt", "powerfmt",
"serde", "serde",
@@ -3112,15 +3287,15 @@ dependencies = [
[[package]] [[package]]
name = "time-core" name = "time-core"
version = "0.1.4" version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
[[package]] [[package]]
name = "time-macros" name = "time-macros"
version = "0.2.22" version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
dependencies = [ dependencies = [
"num-conv", "num-conv",
"time-core", "time-core",
@@ -3334,14 +3509,24 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [ dependencies = [
"bitflags 2.9.4", "bitflags 2.9.4",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http 1.3.1", "http 1.3.1",
"http-body 1.0.1", "http-body 1.0.1",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string", "iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -3577,6 +3762,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uwl" name = "uwl"
version = "0.6.0" version = "0.6.0"

View File

@@ -34,7 +34,7 @@ sqlx = { version = "0.8.6", features = [
"macros", "macros",
] } ] }
thiserror = "2.0.16" thiserror = "2.0.16"
time = "0.3.41" time = "0.3.43"
tokio = { version = "1.47.1", features = ["full"] } tokio = { version = "1.47.1", features = ["full"] }
tl = "0.7.8" tl = "0.7.8"
tracing = "0.1.41" tracing = "0.1.41"
@@ -44,5 +44,9 @@ governor = "0.10.1"
once_cell = "1.21.3" once_cell = "1.21.3"
serde_path_to_error = "0.1.17" serde_path_to_error = "0.1.17"
num-format = "0.4.4" 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] [dev-dependencies]

View File

@@ -20,7 +20,7 @@ pub struct Config {
/// Defaults to "info" if not specified /// Defaults to "info" if not specified
#[serde(default = "default_log_level")] #[serde(default = "default_log_level")]
pub log_level: String, pub log_level: String,
/// Port for the web server /// Port for the web server (default: 8080)
#[serde(default = "default_port")] #[serde(default = "default_port")]
pub port: u16, pub port: u16,
/// Database connection URL /// Database connection URL
@@ -51,9 +51,9 @@ fn default_log_level() -> String {
"info".to_string() "info".to_string()
} }
/// Default port of 3000 /// Default port of 8080
fn default_port() -> u16 { fn default_port() -> u16 {
3000 8080
} }
/// Default shutdown timeout of 8 seconds /// Default shutdown timeout of 8 seconds

View File

@@ -24,14 +24,14 @@ const TIMESTAMP_FORMAT: &[FormatItem<'static>] =
/// A custom formatter with enhanced timestamp formatting /// A custom formatter with enhanced timestamp formatting
/// ///
/// Re-implementation of the Full formatter with improved timestamp display. /// 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 /// A custom JSON formatter that flattens fields to root level
/// ///
/// Outputs logs in the format: { "message": "...", "level": "...", "customAttribute": "..." } /// Outputs logs in the format: { "message": "...", "level": "...", "customAttribute": "..." }
pub struct CustomJsonFormatter; pub struct CustomJsonFormatter;
impl<S, N> FormatEvent<S, N> for CustomFormatter impl<S, N> FormatEvent<S, N> for CustomPrettyFormatter
where where
S: Subscriber + for<'a> LookupSpan<'a>, S: Subscriber + for<'a> LookupSpan<'a>,
N: for<'a> FormatFields<'a> + 'static, N: for<'a> FormatFields<'a> + 'static,

View File

@@ -1,3 +1,4 @@
use clap::Parser;
use figment::value::UncasedStr; use figment::value::UncasedStr;
use num_format::{Locale, ToFormattedString}; use num_format::{Locale, ToFormattedString};
use serenity::all::{ActivityData, ClientBuilder, Context, GatewayIntents}; use serenity::all::{ActivityData, ClientBuilder, Context, GatewayIntents};
@@ -28,6 +29,25 @@ mod services;
mod state; mod state;
mod web; 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> { async fn update_bot_status(ctx: &Context, app_state: &AppState) -> Result<(), anyhow::Error> {
let course_count = app_state.get_course_count().await?; 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() { async fn main() {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
// Parse CLI arguments
let args = Args::parse();
// Load configuration first to get log level // Load configuration first to get log level
let config: Config = Figment::new() let config: Config = Figment::new()
.merge(Env::raw().map(|k| { .merge(Env::raw().map(|k| {
@@ -64,22 +87,31 @@ async fn main() {
base_level 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<dyn tracing::Subscriber + Send + Sync> = if use_pretty {
Box::new(
FmtSubscriber::builder() FmtSubscriber::builder()
.with_target(true) .with_target(true)
.event_format(formatter::CustomFormatter) .event_format(formatter::CustomPrettyFormatter)
} .with_env_filter(filter)
#[cfg(not(debug_assertions))] .finish(),
{ )
} else {
Box::new(
FmtSubscriber::builder() FmtSubscriber::builder()
.with_target(true) .with_target(true)
.event_format(formatter::CustomJsonFormatter) .event_format(formatter::CustomJsonFormatter)
}
}
.with_env_filter(filter) .with_env_filter(filter)
.finish(); .finish(),
)
};
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
// Log application startup context // Log application startup context

64
src/web/assets.rs Normal file
View File

@@ -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<String>) -> 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,
}
}

View File

@@ -1,5 +1,6 @@
//! Web API module for the banner application. //! Web API module for the banner application.
pub mod assets;
pub mod routes; pub mod routes;
pub use routes::*; pub use routes::*;

View File

@@ -1,10 +1,22 @@
//! Web API endpoints for Banner bot monitoring and metrics. //! 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 serde_json::{Value, json};
use std::sync::Arc; use std::sync::Arc;
use tower_http::{
cors::{Any, CorsLayer},
trace::TraceLayer,
};
use tracing::info; use tracing::info;
use crate::web::assets::{is_asset_path, serve_asset, serve_spa_index};
use crate::banner::BannerApi; use crate::banner::BannerApi;
/// Shared application state for web server /// Shared application state for web server
@@ -15,24 +27,72 @@ pub struct BannerState {
/// Creates the web server router /// Creates the web server router
pub fn create_router(state: BannerState) -> Router { pub fn create_router(state: BannerState) -> Router {
Router::new() let api_router = Router::new()
.route("/", get(root))
.route("/health", get(health)) .route("/health", get(health))
.route("/status", get(status)) .route("/status", get(status))
.route("/metrics", get(metrics)) .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<Value> { async fn root() -> Response {
if cfg!(debug_assertions) {
// Development mode: return API info
Json(json!({ Json(json!({
"message": "Banner Discord Bot API", "message": "Banner Discord Bot API",
"version": "0.1.0", "version": "0.2.1",
"mode": "development",
"frontend": "http://localhost:3000",
"endpoints": { "endpoints": {
"health": "/health", "health": "/api/health",
"status": "/status", "status": "/api/status",
"metrics": "/metrics" "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 /// Health check endpoint