From 996ad5c4c85140d73452e3a9ac9542065502d601 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sat, 30 May 2026 17:52:00 +0200 Subject: [PATCH] feat: add CLI configuration flags Add clap-powered --bind, --static-dir, and --data-dir flags for human-run server configuration. The merge order is now explicit: command-line arguments win over UPL_* environment variables, which still fall back to the existing repository-local defaults. Document the new flags and allow just run to forward arguments to cargo so the help text can be checked through the normal task runner. Test Plan: - just check - cargo run -- --help Refs: none --- Cargo.lock | 121 +++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + README.md | 12 +++-- justfile | 4 +- src/app.rs | 140 +++++++++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 2 +- 6 files changed, 266 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85e25f4..dae8f3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,56 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -90,6 +140,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "deranged" version = "0.5.8" @@ -314,6 +410,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -416,6 +518,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -635,6 +743,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.117" @@ -833,6 +947,7 @@ name = "upl" version = "0.1.0" dependencies = [ "axum", + "clap", "http-body-util", "serde", "serde_json", @@ -844,6 +959,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.2" diff --git a/Cargo.toml b/Cargo.toml index f4cee49..639fe9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] axum = "0.8.9" +clap = { version = "4.5.53", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" time = { version = "0.3.47", features = ["formatting", "serde"] } diff --git a/README.md b/README.md index 1d45b7c..3115894 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,13 @@ upl ## Configuration -- `UPL_BIND` sets the listen address. It defaults to `127.0.0.1:3000`. -- `UPL_STATIC_DIR` sets the static asset directory. It defaults to `static/` - inside this repository. -- `UPL_DATA_DIR` sets the upload data directory. It defaults to `data/` inside - this repository. +- `--bind` sets the listen address. It overrides `UPL_BIND` and defaults to + `127.0.0.1:3000`. +- `--static-dir` sets the static asset directory. It overrides `UPL_STATIC_DIR` + and defaults to `static/` inside this repository. +- `--data-dir` sets the upload data directory. It overrides `UPL_DATA_DIR` and + defaults to `data/` inside this repository. +- `upl --help` prints the full argument help text. - The server accepts request bodies up to 64 MiB, which leaves room for the planned 16 MiB upload chunks and matches the nginx example in `PLAN.md`. diff --git a/justfile b/justfile index b4ad1f0..a1b5143 100644 --- a/justfile +++ b/justfile @@ -20,5 +20,5 @@ check: just static-check just clippy -run: - cargo run +run *args: + cargo run -- {{args}} diff --git a/src/app.rs b/src/app.rs index 36711cd..62b05d6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,7 @@ use std::{ env, error::Error, + ffi::OsString, net::SocketAddr, path::{Path, PathBuf}, }; @@ -10,6 +11,7 @@ use axum::{ extract::DefaultBodyLimit, routing::{get, post}, }; +use clap::Parser; use tower_http::services::{ServeDir, ServeFile}; use crate::{api, storage::Storage}; @@ -32,18 +34,79 @@ pub struct AppState { pub storage: Storage, } +#[derive(Clone, Debug, Default, Parser)] +#[command( + name = "upl", + version, + about = "Run the upl resumable upload server.", + long_about = "Run the upl resumable upload server.\n\nCommand-line arguments override environment variables. When neither is set, upl uses local development defaults inside the repository.", + after_help = "Environment variables:\n UPL_BIND Default listen address\n UPL_STATIC_DIR Default static asset directory\n UPL_DATA_DIR Default upload data directory" +)] +pub struct CliArgs { + /// Socket address to listen on. Overrides `UPL_BIND`. Defaults to 127.0.0.1:3000. + #[arg(long, value_name = "ADDR")] + pub bind: Option, + + /// Directory containing index.html and other browser assets. Overrides `UPL_STATIC_DIR`. + #[arg(long, value_name = "PATH")] + pub static_dir: Option, + + /// Directory where upload staging chunks and completed files are written. Overrides `UPL_DATA_DIR`. + #[arg(long, value_name = "PATH")] + pub data_dir: Option, +} + impl AppConfig { - /// Loads bind and static directory settings from environment variables. + /// Loads settings from command-line arguments and environment variables. + /// + /// Command-line arguments take precedence over environment variables. + /// + /// # Errors + /// + /// Returns an error when an argument or `UPL_BIND` is not a valid socket + /// address. + pub fn from_args() -> Result> { + Self::from_cli_and_env(CliArgs::parse()) + } + + /// Loads settings from environment variables. /// /// # Errors /// /// Returns an error when `UPL_BIND` is set but is not a valid socket address. pub fn from_env() -> Result> { - let bind_addr = env::var(BIND_ENV) - .unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_owned()) - .parse()?; - let static_dir = env::var_os(STATIC_DIR_ENV).map_or_else(default_static_dir, PathBuf::from); - let data_dir = env::var_os(DATA_DIR_ENV).map_or_else(default_data_dir, PathBuf::from); + Self::from_cli_and_env(CliArgs::default()) + } + + fn from_cli_and_env(cli: CliArgs) -> Result> { + Self::from_sources( + cli, + env::var(BIND_ENV).ok(), + env::var_os(STATIC_DIR_ENV), + env::var_os(DATA_DIR_ENV), + ) + } + + fn from_sources( + cli: CliArgs, + bind_env: Option, + static_dir_env: Option, + data_dir_env: Option, + ) -> Result> { + let bind_addr = match (cli.bind, bind_env) { + (Some(bind_addr), _) => bind_addr, + (None, Some(bind_addr)) => bind_addr.parse()?, + (None, None) => DEFAULT_BIND_ADDR.parse()?, + }; + + let static_dir = cli + .static_dir + .or_else(|| static_dir_env.map(PathBuf::from)) + .unwrap_or_else(default_static_dir); + let data_dir = cli + .data_dir + .or_else(|| data_dir_env.map(PathBuf::from)) + .unwrap_or_else(default_data_dir); Ok(Self { bind_addr, @@ -103,3 +166,68 @@ fn default_static_dir() -> PathBuf { fn default_data_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data") } + +#[cfg(test)] +mod tests { + use std::{ffi::OsString, net::SocketAddr, path::PathBuf}; + + use clap::Parser; + + use super::{AppConfig, CliArgs}; + + #[test] + fn parses_config_arguments() -> Result<(), Box> { + let args = CliArgs::try_parse_from([ + "upl", + "--bind", + "127.0.0.1:4000", + "--static-dir", + "public", + "--data-dir", + "uploads", + ])?; + + assert_eq!(args.bind, Some("127.0.0.1:4000".parse()?)); + assert_eq!(args.static_dir, Some(PathBuf::from("public"))); + assert_eq!(args.data_dir, Some(PathBuf::from("uploads"))); + + Ok(()) + } + + #[test] + fn cli_arguments_override_environment_values() -> Result<(), Box> { + let config = AppConfig::from_sources( + CliArgs { + bind: Some("127.0.0.1:4000".parse()?), + static_dir: Some(PathBuf::from("cli-static")), + data_dir: Some(PathBuf::from("cli-data")), + }, + Some("127.0.0.1:3001".to_owned()), + Some(OsString::from("env-static")), + Some(OsString::from("env-data")), + )?; + + assert_eq!(config.bind_addr, "127.0.0.1:4000".parse::()?); + assert_eq!(config.static_dir, PathBuf::from("cli-static")); + assert_eq!(config.data_dir, PathBuf::from("cli-data")); + + Ok(()) + } + + #[test] + fn environment_values_are_used_when_arguments_are_absent() + -> Result<(), Box> { + let config = AppConfig::from_sources( + CliArgs::default(), + Some("127.0.0.1:3001".to_owned()), + Some(OsString::from("env-static")), + Some(OsString::from("env-data")), + )?; + + assert_eq!(config.bind_addr, "127.0.0.1:3001".parse::()?); + assert_eq!(config.static_dir, PathBuf::from("env-static")); + assert_eq!(config.data_dir, PathBuf::from("env-data")); + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index b8e9b64..49d5f4a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use upl::app::{AppConfig, build_router}; #[tokio::main] async fn main() -> Result<(), Box> { - let config = AppConfig::from_env()?; + let config = AppConfig::from_args()?; let listener = tokio::net::TcpListener::bind(config.bind_addr).await?; println!("upl listening on http://{}", listener.local_addr()?);