From 9e403e5043c05b8914604925c5eacc2b1b60c5fc Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 29 Jan 2026 14:28:06 -0600 Subject: [PATCH] refactor: modernize Justfile commands and simplify service management --- Dockerfile | 4 +- Justfile | 371 ++++++++++++++++++++++++++++++++++++---------------- README.md | 3 +- src/cli.rs | 106 +-------------- src/main.rs | 7 +- 5 files changed, 267 insertions(+), 224 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3550631..b610500 100644 --- a/Dockerfile +++ b/Dockerfile @@ -115,5 +115,5 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ ENV HOSTS=0.0.0.0,[::] # Implicitly uses PORT environment variable -# temporary: running without 'scraper' service -CMD ["sh", "-c", "exec ./banner --services web,bot"] +# Runs all services: web, bot, and scraper +CMD ["sh", "-c", "exec ./banner"] diff --git a/Justfile b/Justfile index 0ff4c76..ac319ec 100644 --- a/Justfile +++ b/Justfile @@ -1,51 +1,280 @@ set dotenv-load -default_services := "bot,web,scraper" default: just --list -# Run all checks (format, clippy, tests, lint) -check: - cargo fmt --all -- --check - cargo clippy --all-features -- --deny warnings - cargo nextest run -E 'not test(export_bindings)' - bun run --cwd web check - bun run --cwd web test +# Run all checks in parallel. Pass -f/--fix to auto-format and fix first. +[script("bun")] +check *flags: + const args = "{{flags}}".split(/\s+/).filter(Boolean); + let fix = false; + for (const arg of args) { + if (arg === "-f" || arg === "--fix") fix = true; + else { console.error(`Unknown flag: ${arg}`); process.exit(1); } + } -# Generate TypeScript bindings from Rust types (ts-rs) -bindings: - cargo test export_bindings + const run = (cmd) => { + const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] }); + if (proc.exitCode !== 0) process.exit(proc.exitCode); + }; -# Run all tests (Rust + frontend) -test: test-rust test-web + if (fix) { + console.log("\x1b[1;36m→ Fixing...\x1b[0m"); + run(["cargo", "fmt", "--all"]); + run(["bun", "run", "--cwd", "web", "format"]); + run(["cargo", "clippy", "--all-features", "--fix", "--allow-dirty", "--allow-staged", + "--", "--deny", "warnings"]); + console.log("\x1b[1;36m→ Verifying...\x1b[0m"); + } -# Run only Rust tests (excludes ts-rs bindings generation) -test-rust *ARGS: - cargo nextest run -E 'not test(export_bindings)' {{ARGS}} + const checks = [ + { name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"] }, + { name: "clippy", cmd: ["cargo", "clippy", "--all-features", "--", "--deny", "warnings"] }, + { name: "rust-test", cmd: ["cargo", "nextest", "run", "-E", "not test(export_bindings)"] }, + { name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] }, + { name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] }, + { name: "web-test", cmd: ["bun", "run", "--cwd", "web", "test"] }, + // { name: "sqlx-prepare", cmd: ["cargo", "sqlx", "prepare", "--check"] }, + ]; -# Run only frontend tests -test-web: - bun run --cwd web test + const isTTY = process.stderr.isTTY; + const start = Date.now(); + const remaining = new Set(checks.map(c => c.name)); -# Quick check: clippy + tests + typecheck (skips formatting) -check-quick: - cargo clippy --all-features -- --deny warnings - cargo nextest run -E 'not test(export_bindings)' - bun run --cwd web check + const promises = checks.map(async (check) => { + const proc = Bun.spawn(check.cmd, { + env: { ...process.env, FORCE_COLOR: "1" }, + stdout: "pipe", stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + await proc.exited; + return { ...check, stdout, stderr, exitCode: proc.exitCode, + elapsed: ((Date.now() - start) / 1000).toFixed(1) }; + }); -# Run the Banner API search demo (hits live UTSA API, ~20s) -search *ARGS: - cargo run -q --bin search -- {{ARGS}} + const interval = isTTY ? setInterval(() => { + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + process.stderr.write(`\r\x1b[K${elapsed}s [${Array.from(remaining).join(", ")}]`); + }, 100) : null; + + let anyFailed = false; + for (const promise of promises) { + const r = await promise; + remaining.delete(r.name); + if (isTTY) process.stderr.write(`\r\x1b[K`); + if (r.exitCode !== 0) { + anyFailed = true; + process.stdout.write(`\x1b[31m✗ ${r.name}\x1b[0m (${r.elapsed}s)\n`); + if (r.stdout) process.stdout.write(r.stdout); + if (r.stderr) process.stderr.write(r.stderr); + } else { + process.stdout.write(`\x1b[32m✓ ${r.name}\x1b[0m (${r.elapsed}s)\n`); + } + } + + if (interval) clearInterval(interval); + if (isTTY) process.stderr.write(`\r\x1b[K`); + process.exit(anyFailed ? 1 : 0); # Format all Rust and TypeScript code format: cargo fmt --all bun run --cwd web format -# Check formatting without modifying (CI-friendly) -format-check: - cargo fmt --all -- --check - bun run --cwd web format:check +# Run tests. Usage: just test [rust|web|] +[script("bun")] +test *args: + const input = "{{args}}".trim(); + const run = (cmd) => { + const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] }); + if (proc.exitCode !== 0) process.exit(proc.exitCode); + }; + if (input === "web") { + run(["bun", "run", "--cwd", "web", "test"]); + } else if (input === "rust") { + run(["cargo", "nextest", "run", "-E", "not test(export_bindings)"]); + } else if (input === "") { + run(["cargo", "nextest", "run", "-E", "not test(export_bindings)"]); + run(["bun", "run", "--cwd", "web", "test"]); + } else { + run(["cargo", "nextest", "run", ...input.split(/\s+/)]); + } + +# Generate TypeScript bindings from Rust types (ts-rs) +bindings: + cargo test export_bindings + +# Run the Banner API search demo (hits live UTSA API, ~20s) +search *ARGS: + cargo run -q --bin search -- {{ARGS}} + +# Pass args to binary after --: just dev -n -- --some-flag +# Dev server. Flags: -f(rontend) -b(ackend) -W(no-watch) -n(o-build) -r(elease) -e(mbed) --tracing +[script("bun")] +dev *flags: + const argv = "{{flags}}".split(/\s+/).filter(Boolean); + + let frontendOnly = false, backendOnly = false; + let noWatch = false, noBuild = false, release = false, embed = false; + let tracing = "pretty"; + const passthrough = []; + + let i = 0; + let seenDashDash = false; + while (i < argv.length) { + const arg = argv[i]; + if (seenDashDash) { passthrough.push(arg); i++; continue; } + if (arg === "--") { seenDashDash = true; i++; continue; } + if (arg.startsWith("--")) { + if (arg === "--frontend-only") frontendOnly = true; + else if (arg === "--backend-only") backendOnly = true; + else if (arg === "--no-watch") noWatch = true; + else if (arg === "--no-build") noBuild = true; + else if (arg === "--release") release = true; + else if (arg === "--embed") embed = true; + else if (arg === "--tracing") { tracing = argv[++i] || "pretty"; } + else { console.error(`Unknown flag: ${arg}`); process.exit(1); } + } else if (arg.startsWith("-") && arg.length > 1) { + for (const c of arg.slice(1)) { + if (c === "f") frontendOnly = true; + else if (c === "b") backendOnly = true; + else if (c === "W") noWatch = true; + else if (c === "n") noBuild = true; + else if (c === "r") release = true; + else if (c === "e") embed = true; + else { console.error(`Unknown flag: -${c}`); process.exit(1); } + } + } else { console.error(`Unknown argument: ${arg}`); process.exit(1); } + i++; + } + + // -e implies -b (no point running Vite if assets are embedded) + if (embed) backendOnly = true; + // -n implies -W (no build means no watch) + if (noBuild) noWatch = true; + + // Validate conflicting flags + if (frontendOnly && backendOnly) { + console.error("Cannot use -f and -b together (or -e implies -b)"); + process.exit(1); + } + + const runFrontend = !backendOnly; + const runBackend = !frontendOnly; + const profile = release ? "release" : "dev"; + const profileDir = release ? "release" : "debug"; + + const procs = []; + const cleanup = () => { for (const p of procs) p.kill(); }; + process.on("SIGINT", () => { cleanup(); process.exit(130); }); + process.on("SIGTERM", () => { cleanup(); process.exit(143); }); + + // Frontend: Vite dev server + if (runFrontend) { + const proc = Bun.spawn(["bun", "run", "--cwd", "web", "dev"], { + stdio: ["inherit", "inherit", "inherit"], + }); + procs.push(proc); + } + + // Backend + if (runBackend) { + const backendArgs = [`--tracing`, tracing, ...passthrough]; + const bin = `target/${profileDir}/banner`; + + if (noWatch) { + // Build first unless -n (skip build) + if (!noBuild) { + console.log(`\x1b[1;36m→ Building backend (${profile})...\x1b[0m`); + const cargoArgs = ["cargo", "build", "--bin", "banner"]; + if (!embed) cargoArgs.push("--no-default-features"); + if (release) cargoArgs.push("--release"); + const build = Bun.spawnSync(cargoArgs, { stdio: ["inherit", "inherit", "inherit"] }); + if (build.exitCode !== 0) { cleanup(); process.exit(build.exitCode); } + } + + // Run the binary directly (no watch) + const { existsSync } = await import("fs"); + if (!existsSync(bin)) { + console.error(`Binary not found: ${bin}`); + console.error(`Run 'just build${release ? "" : " -d"}' first, or remove -n to use bacon.`); + cleanup(); + process.exit(1); + } + console.log(`\x1b[1;36m→ Running ${bin} (no watch)\x1b[0m`); + const proc = Bun.spawn([bin, ...backendArgs], { + stdio: ["inherit", "inherit", "inherit"], + }); + procs.push(proc); + } else { + // Bacon watch mode + const baconArgs = ["bacon", "--headless", "run", "--"]; + if (!embed) baconArgs.push("--no-default-features"); + if (release) baconArgs.push("--profile", "release"); + baconArgs.push("--", ...backendArgs); + const proc = Bun.spawn(baconArgs, { + stdio: ["inherit", "inherit", "inherit"], + }); + procs.push(proc); + } + } + + // Wait for any process to exit, then kill the rest + const results = procs.map((p, i) => p.exited.then(code => ({ i, code }))); + const first = await Promise.race(results); + cleanup(); + process.exit(first.code); + +# Production build. Flags: -d(ebug) -f(rontend-only) -b(ackend-only) +[script("bun")] +build *flags: + const argv = "{{flags}}".split(/\s+/).filter(Boolean); + + let debug = false, frontendOnly = false, backendOnly = false; + for (const arg of argv) { + if (arg.startsWith("--")) { + if (arg === "--debug") debug = true; + else if (arg === "--frontend-only") frontendOnly = true; + else if (arg === "--backend-only") backendOnly = true; + else { console.error(`Unknown flag: ${arg}`); process.exit(1); } + } else if (arg.startsWith("-") && arg.length > 1) { + for (const c of arg.slice(1)) { + if (c === "d") debug = true; + else if (c === "f") frontendOnly = true; + else if (c === "b") backendOnly = true; + else { console.error(`Unknown flag: -${c}`); process.exit(1); } + } + } else { console.error(`Unknown argument: ${arg}`); process.exit(1); } + } + + if (frontendOnly && backendOnly) { + console.error("Cannot use -f and -b together"); + process.exit(1); + } + + const run = (cmd) => { + const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] }); + if (proc.exitCode !== 0) process.exit(proc.exitCode); + }; + + const buildFrontend = !backendOnly; + const buildBackend = !frontendOnly; + const profile = debug ? "debug" : "release"; + + if (buildFrontend) { + console.log("\x1b[1;36m→ Building frontend...\x1b[0m"); + run(["bun", "run", "--cwd", "web", "build"]); + } + + if (buildBackend) { + console.log(`\x1b[1;36m→ Building backend (${profile})...\x1b[0m`); + const cmd = ["cargo", "build", "--bin", "banner"]; + if (!debug) cmd.push("--release"); + run(cmd); + } # Start PostgreSQL in Docker and update .env with connection string # Commands: start (default), reset, rm @@ -115,86 +344,6 @@ db cmd="start": await updateEnv(); } -# Auto-reloading frontend server -frontend: - bun run --cwd web dev - -# Production build of frontend -build-frontend: - bun run --cwd web build - -# Auto-reloading backend server (with embedded assets) -backend *ARGS: - bacon --headless run -- -- {{ARGS}} - -# Auto-reloading backend server (no embedded assets, for dev proxy mode) -backend-dev *ARGS: - bacon --headless run -- --no-default-features -- {{ARGS}} - -# Production build -build: - bun run --cwd web build - cargo build --release --bin banner - -# Run auto-reloading development build with release characteristics -dev-build *ARGS='--services web --tracing pretty': build-frontend - bacon --headless run -- --profile dev-release -- {{ARGS}} - -# Auto-reloading development build: Vite frontend + backend (no embedded assets, proxies to Vite) -[parallel] -dev *ARGS='--services web,bot': frontend (backend-dev ARGS) - -# Smoke test: start web server, hit API endpoints, verify responses -[script("bash")] -test-smoke port="18080": - set -euo pipefail - PORT={{port}} - - cleanup() { kill "$SERVER_PID" 2>/dev/null; wait "$SERVER_PID" 2>/dev/null; } - - # Start server in background - PORT=$PORT cargo run -q --no-default-features -- --services web --tracing json & - SERVER_PID=$! - trap cleanup EXIT - - # Wait for server to be ready (up to 15s) - for i in $(seq 1 30); do - if curl -sf "http://localhost:$PORT/api/health" >/dev/null 2>&1; then break; fi - if ! kill -0 "$SERVER_PID" 2>/dev/null; then echo "FAIL: server exited early"; exit 1; fi - sleep 0.5 - done - - PASS=0; FAIL=0 - check() { - local label="$1" url="$2" expected="$3" - body=$(curl -sf "$url") || { echo "FAIL: $label - request failed"; FAIL=$((FAIL+1)); return; } - if echo "$body" | grep -q "$expected"; then - echo "PASS: $label" - PASS=$((PASS+1)) - else - echo "FAIL: $label - expected '$expected' in: $body" - FAIL=$((FAIL+1)) - fi - } - - check "GET /api/health" "http://localhost:$PORT/api/health" '"status":"healthy"' - check "GET /api/status" "http://localhost:$PORT/api/status" '"version"' - check "GET /api/metrics" "http://localhost:$PORT/api/metrics" '"banner_api"' - - # Test 404 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$PORT/api/nonexistent") - if [ "$STATUS" = "404" ]; then - echo "PASS: 404 on unknown route" - PASS=$((PASS+1)) - else - echo "FAIL: expected 404, got $STATUS" - FAIL=$((FAIL+1)) - fi - - echo "" - echo "Results: $PASS passed, $FAIL failed" - [ "$FAIL" -eq 0 ] - alias b := bun bun *ARGS: cd web && bun {{ ARGS }} diff --git a/README.md b/README.md index 7ef4c31..61a5c10 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,7 @@ The application consists of three modular services that can be run independently bun install --cwd web # Install frontend dependencies cargo build # Build the backend -just dev # Runs auto-reloading dev build -just dev --services bot,web # Runs auto-reloading dev build, running only the bot and web services +just dev # Runs auto-reloading dev build with all services just dev-build # Development build with release characteristics (frontend is embedded, non-auto-reloading) just build # Production build that embeds assets diff --git a/src/cli.rs b/src/cli.rs index 9cd6d75..73cfc1a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,34 +2,16 @@ use clap::Parser; /// Banner Discord Bot - Course availability monitoring /// -/// This application runs multiple services that can be controlled via CLI arguments: +/// This application runs all services: /// - bot: Discord bot for course monitoring commands /// - web: HTTP server for web interface and API /// - scraper: Background service for scraping course data -/// -/// Use --services to specify which services to run, or --disable-services to exclude specific services. #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] pub struct Args { /// Log formatter to use #[arg(long, value_enum, default_value_t = default_tracing_format())] pub tracing: TracingFormat, - - /// Services to run (comma-separated). Default: all services - /// - /// Examples: - /// --services bot,web # Run only bot and web services - /// --services scraper # Run only the scraper service - #[arg(long, value_delimiter = ',', conflicts_with = "disable_services")] - pub services: Option>, - - /// Services to disable (comma-separated) - /// - /// Examples: - /// --disable-services bot # Run web and scraper only - /// --disable-services bot,web # Run only the scraper service - #[arg(long, value_delimiter = ',', conflicts_with = "services")] - pub disable_services: Option>, } #[derive(clap::ValueEnum, Clone, Debug)] @@ -66,34 +48,6 @@ impl ServiceName { } } -/// Determine which services should be enabled based on CLI arguments -pub fn determine_enabled_services(args: &Args) -> Result, anyhow::Error> { - match (&args.services, &args.disable_services) { - (Some(services), None) => { - // User specified which services to run - Ok(services.clone()) - } - (None, Some(disabled)) => { - // User specified which services to disable - let enabled: Vec = ServiceName::all() - .into_iter() - .filter(|s| !disabled.contains(s)) - .collect(); - Ok(enabled) - } - (None, None) => { - // Default: run all services - Ok(ServiceName::all()) - } - (Some(_), Some(_)) => { - // This should be prevented by clap's conflicts_with, but just in case - Err(anyhow::anyhow!( - "Cannot specify both --services and --disable-services" - )) - } - } -} - #[cfg(debug_assertions)] const DEFAULT_TRACING_FORMAT: TracingFormat = TracingFormat::Pretty; #[cfg(not(debug_assertions))] @@ -107,64 +61,6 @@ fn default_tracing_format() -> TracingFormat { mod tests { use super::*; - fn args_with_services( - services: Option>, - disable: Option>, - ) -> Args { - Args { - tracing: TracingFormat::Pretty, - services, - disable_services: disable, - } - } - - #[test] - fn test_default_enables_all_services() { - let result = determine_enabled_services(&args_with_services(None, None)).unwrap(); - assert_eq!(result.len(), 3); - } - - #[test] - fn test_explicit_services_only_those() { - let result = - determine_enabled_services(&args_with_services(Some(vec![ServiceName::Web]), None)) - .unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(result[0].as_str(), "web"); - } - - #[test] - fn test_disable_bot_leaves_web_and_scraper() { - let result = - determine_enabled_services(&args_with_services(None, Some(vec![ServiceName::Bot]))) - .unwrap(); - assert_eq!(result.len(), 2); - assert!(result.iter().all(|s| s.as_str() != "bot")); - } - - #[test] - fn test_disable_all_leaves_empty() { - let result = determine_enabled_services(&args_with_services( - None, - Some(vec![ - ServiceName::Bot, - ServiceName::Web, - ServiceName::Scraper, - ]), - )) - .unwrap(); - assert!(result.is_empty()); - } - - #[test] - fn test_both_specified_returns_error() { - let result = determine_enabled_services(&args_with_services( - Some(vec![ServiceName::Web]), - Some(vec![ServiceName::Bot]), - )); - assert!(result.is_err()); - } - #[test] fn test_service_name_as_str() { assert_eq!(ServiceName::Bot.as_str(), "bot"); diff --git a/src/main.rs b/src/main.rs index 496b600..294f490 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use crate::app::App; -use crate::cli::{Args, ServiceName, determine_enabled_services}; +use crate::cli::{Args, ServiceName}; use crate::logging::setup_logging; use clap::Parser; use std::process::ExitCode; @@ -29,9 +29,8 @@ async fn main() -> ExitCode { // Parse CLI arguments let args = Args::parse(); - // Determine which services should be enabled - let enabled_services: Vec = - determine_enabled_services(&args).expect("Failed to determine enabled services"); + // Always run all services + let enabled_services = ServiceName::all(); // Create and initialize the application let mut app = App::new().await.expect("Failed to initialize application");