use crate::config::{BackendSelectionStrategy, Config, RoutingMode};
use crate::types::{CacheCapacity, ConfigPath, Port, ThreadCount};
use anyhow::{Result, bail};
use clap::{Parser, ValueEnum};
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::time::Duration;
fn parse_port(s: &str) -> Result<Port, String> {
let port: u16 = s.parse().map_err(|e| format!("Invalid port number: {e}"))?;
Port::try_new(port).map_err(|e| format!("Invalid port: {e}"))
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum UiMode {
Headless,
Tui,
}
impl UiMode {
pub const DEFAULT: Self = Self::Headless;
#[must_use]
pub const fn uses_tui(self) -> bool {
matches!(self, Self::Tui)
}
}
#[derive(Parser, Debug, Clone)]
pub struct CommonArgs {
#[arg(
short,
long,
default_value = "config.toml",
env = "NNTP_PROXY_CONFIG",
help_heading = "General"
)]
pub config: ConfigPath,
#[arg(
long,
value_enum,
value_name = "MODE",
default_value_t = UiMode::DEFAULT,
env = "NNTP_PROXY_UI",
help_heading = "General"
)]
pub ui: UiMode,
#[arg(long, help_heading = "General", conflicts_with_all = ["ui", "headless"])]
pub tui: bool,
#[arg(long, help_heading = "General", conflicts_with_all = ["ui", "tui", "no_tui"])]
pub headless: bool,
#[arg(long, hide = true, help_heading = "General")]
pub no_tui: bool,
#[arg(
long = "tui-listen",
value_name = "IP:PORT",
env = "NNTP_PROXY_TUI_LISTEN",
help_heading = "General"
)]
pub tui_listen: Option<SocketAddr>,
#[arg(
long = "tui-attach",
value_name = "IP:PORT",
env = "NNTP_PROXY_TUI_ATTACH",
help_heading = "General"
)]
pub tui_attach: Option<SocketAddr>,
#[arg(
short,
long,
env = "NNTP_PROXY_PORT",
value_parser = parse_port,
help_heading = "Network"
)]
pub port: Option<Port>,
#[arg(long, env = "NNTP_PROXY_HOST", help_heading = "Network")]
pub host: Option<String>,
#[arg(
short = 'm',
long = "routing-mode",
value_enum,
env = "NNTP_PROXY_ROUTING_MODE",
help_heading = "Routing"
)]
pub routing_mode: Option<RoutingMode>,
#[arg(
long = "backend-selection",
alias = "backend-strategy",
value_enum,
env = "NNTP_PROXY_BACKEND_SELECTION",
help_heading = "Routing"
)]
pub backend_selection: Option<BackendSelectionStrategy>,
#[arg(
long = "article-cache-capacity",
alias = "cache-capacity",
env = "NNTP_PROXY_ARTICLE_CACHE_CAPACITY",
help_heading = "Cache"
)]
pub article_cache_capacity: Option<CacheCapacity>,
#[arg(
long = "article-cache-ttl",
alias = "cache-ttl",
alias = "ttl-secs",
env = "NNTP_PROXY_ARTICLE_CACHE_TTL_SECS",
help_heading = "Cache"
)]
pub article_cache_ttl_secs: Option<u64>,
#[arg(
long = "store-article-bodies",
alias = "cache-articles",
alias = "store-articles",
env = "NNTP_PROXY_STORE_ARTICLE_BODIES",
help_heading = "Cache"
)]
pub store_article_bodies: Option<bool>,
#[arg(short, long, env = "NNTP_PROXY_THREADS", help_heading = "Performance")]
pub threads: Option<ThreadCount>,
}
impl CommonArgs {
const DEFAULT_PORT: u16 = 8119;
const DEFAULT_HOST: &'static str = "0.0.0.0";
#[must_use]
pub fn listen_addr(&self, config_port: Option<Port>) -> String {
let port = self
.effective_port(config_port)
.map_or(Self::DEFAULT_PORT, |p| p.get());
format!("{}:{}", self.effective_host(), port)
}
#[must_use]
pub fn effective_port(&self, config_port: Option<Port>) -> Option<Port> {
self.port.or(config_port)
}
#[must_use]
pub fn effective_host(&self) -> &str {
self.host.as_deref().unwrap_or(Self::DEFAULT_HOST)
}
#[must_use]
pub const fn effective_ui_mode(&self) -> UiMode {
if self.no_tui || self.headless {
UiMode::Headless
} else if self.tui {
UiMode::Tui
} else {
self.ui
}
}
pub fn validate_runtime_mode(&self) -> Result<()> {
let ui_mode = self.effective_ui_mode();
if self.tui_attach.is_some() && ui_mode != UiMode::Tui {
bail!("--tui-attach requires TUI mode; use --ui tui or --tui");
}
if self.tui_listen.is_some() && ui_mode != UiMode::Headless {
bail!("--tui-listen requires headless mode; use --ui headless or --headless");
}
if self.tui_attach.is_some() && self.tui_listen.is_some() {
bail!("--tui-attach cannot be combined with --tui-listen");
}
if let Some(attach_addr) = self.tui_attach
&& !attach_addr.ip().is_loopback()
{
bail!(
"--tui-attach {attach_addr} must connect to a loopback address; use 127.0.0.1 or ::1"
);
}
Ok(())
}
pub fn validate_dashboard_listen(&self, proxy_host: &str, proxy_port: Port) -> Result<()> {
let Some(dashboard_listen) = self.tui_listen else {
return Ok(());
};
if !dashboard_listen.ip().is_loopback() {
bail!(
"--tui-listen {dashboard_listen} must bind to a loopback address; use 127.0.0.1 or ::1"
);
}
if dashboard_listen.port() != proxy_port.get() {
return Ok(());
}
if proxy_listener_conflicts(proxy_host, proxy_port, dashboard_listen) {
bail!(
"--tui-listen {} conflicts with the proxy listener {}:{}; use a different port",
dashboard_listen,
proxy_host,
proxy_port.get()
);
}
Ok(())
}
fn legacy_env_var<E>(env_get: &E, keys: &[&str]) -> Option<String>
where
E: Fn(&str) -> Option<String>,
{
keys.iter().find_map(|key| env_get(key))
}
fn env_bool<E>(env_get: &E, keys: &[&str]) -> Option<bool>
where
E: Fn(&str) -> Option<String>,
{
Self::legacy_env_var(env_get, keys)?.parse().ok()
}
fn env_u64<E>(env_get: &E, keys: &[&str]) -> Option<u64>
where
E: Fn(&str) -> Option<String>,
{
Self::legacy_env_var(env_get, keys)?.parse().ok()
}
fn env_cache_capacity<E>(env_get: &E, keys: &[&str]) -> Option<CacheCapacity>
where
E: Fn(&str) -> Option<String>,
{
Self::legacy_env_var(env_get, keys)?.parse().ok()
}
fn apply_overrides_with_env<E>(&self, config: &mut Config, env_get: E)
where
E: Fn(&str) -> Option<String>,
{
if let Some(strategy) = self.backend_selection {
config.routing.backend_selection = strategy;
}
if let Some(rm) = self.routing_mode {
config.routing.routing_mode = rm;
}
let article_cache_capacity = self
.article_cache_capacity
.or_else(|| Self::env_cache_capacity(&env_get, &["NNTP_PROXY_CACHE_CAPACITY"]));
let article_cache_ttl_secs = self
.article_cache_ttl_secs
.or_else(|| Self::env_u64(&env_get, &["NNTP_PROXY_CACHE_TTL"]));
let store_article_bodies = self
.store_article_bodies
.or_else(|| Self::env_bool(&env_get, &["NNTP_PROXY_CACHE_ARTICLES"]));
if article_cache_capacity.is_some()
|| article_cache_ttl_secs.is_some()
|| store_article_bodies.is_some()
{
let cache = config
.cache
.get_or_insert_with(crate::config::Cache::default);
if let Some(cap) = article_cache_capacity {
cache.article_cache_capacity = cap;
}
if let Some(ttl) = article_cache_ttl_secs {
cache.article_cache_ttl_secs = Duration::from_secs(ttl);
}
if let Some(articles) = store_article_bodies {
cache.store_article_bodies = articles;
}
}
}
pub fn apply_overrides(&self, config: &mut Config) {
self.apply_overrides_with_env(config, |key| std::env::var(key).ok());
}
}
fn proxy_listener_conflicts(
proxy_host: &str,
proxy_port: Port,
dashboard_listen: SocketAddr,
) -> bool {
let dashboard_ip = dashboard_listen.ip();
let proxy_target = (proxy_host, proxy_port.get());
proxy_target.to_socket_addrs().ok().map_or_else(
|| dashboard_ip.is_unspecified(),
|addrs| {
addrs
.map(|addr| addr.ip())
.any(|proxy_ip| listener_ips_conflict(proxy_ip, dashboard_ip))
},
)
}
fn listener_ips_conflict(proxy_ip: IpAddr, dashboard_ip: IpAddr) -> bool {
proxy_ip.is_unspecified() || dashboard_ip.is_unspecified() || proxy_ip == dashboard_ip
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn test_common_args_defaults() {
let args = default_args();
assert_eq!(args.listen_addr(None), "0.0.0.0:8119");
assert_eq!(args.effective_host(), "0.0.0.0");
assert!(args.effective_port(None).is_none());
assert_eq!(args.effective_ui_mode(), UiMode::Headless);
}
#[test]
fn test_common_args_with_port() {
let args = CommonArgs {
port: Some(Port::try_new(9119).unwrap()),
..default_args()
};
assert_eq!(args.listen_addr(None), "0.0.0.0:9119");
assert_eq!(
args.effective_port(None),
Some(Port::try_new(9119).unwrap())
);
}
#[test]
fn test_common_args_with_host() {
let args = CommonArgs {
host: Some("127.0.0.1".to_string()),
..default_args()
};
assert_eq!(args.listen_addr(None), "127.0.0.1:8119");
assert_eq!(args.effective_host(), "127.0.0.1");
}
#[test]
fn test_common_args_port_override() {
let args = CommonArgs {
port: Some(Port::try_new(9119).unwrap()),
..default_args()
};
let config_port = Some(Port::try_new(7119).unwrap());
assert_eq!(args.listen_addr(config_port), "0.0.0.0:9119");
assert_eq!(
args.effective_port(config_port),
Some(Port::try_new(9119).unwrap())
);
}
#[test]
fn test_common_args_config_port_fallback() {
let args = default_args();
let config_port = Some(Port::try_new(7119).unwrap());
assert_eq!(args.listen_addr(config_port), "0.0.0.0:7119");
assert_eq!(
args.effective_port(config_port),
Some(Port::try_new(7119).unwrap())
);
}
#[test]
fn test_common_args_custom_host_and_port() {
let args = CommonArgs {
port: Some(Port::try_new(9119).unwrap()),
host: Some("192.168.1.1".to_string()),
..default_args()
};
assert_eq!(args.listen_addr(None), "192.168.1.1:9119");
assert_eq!(args.effective_host(), "192.168.1.1");
assert_eq!(
args.effective_port(None),
Some(Port::try_new(9119).unwrap())
);
}
#[test]
fn test_routing_modes() {
let hybrid = CommonArgs {
routing_mode: Some(RoutingMode::Hybrid),
..default_args()
};
assert_eq!(hybrid.routing_mode, Some(RoutingMode::Hybrid));
let stateful = CommonArgs {
routing_mode: Some(RoutingMode::Stateful),
..default_args()
};
assert_eq!(stateful.routing_mode, Some(RoutingMode::Stateful));
let per_command = CommonArgs {
routing_mode: Some(RoutingMode::PerCommand),
..default_args()
};
assert_eq!(per_command.routing_mode, Some(RoutingMode::PerCommand));
}
#[test]
fn test_thread_count() {
let default_threads = CommonArgs {
threads: None,
..default_args()
};
assert!(default_threads.threads.is_none());
let single_thread = CommonArgs {
threads: Some(ThreadCount::new(1).unwrap()),
..default_args()
};
assert_eq!(single_thread.threads, Some(ThreadCount::new(1).unwrap()));
let multi_thread = CommonArgs {
threads: Some(ThreadCount::new(4).unwrap()),
..default_args()
};
assert_eq!(multi_thread.threads, Some(ThreadCount::new(4).unwrap()));
}
#[test]
fn test_common_args_parse_primary_and_legacy_flags() {
let args = CommonArgs::parse_from([
"nntp-proxy",
"--ui",
"tui",
"--article-cache-capacity",
"128mb",
"--backend-selection",
"least-loaded",
"--article-cache-ttl",
"7200",
"--store-article-bodies",
"true",
]);
assert_eq!(
args.backend_selection,
Some(BackendSelectionStrategy::LeastLoaded)
);
assert_eq!(
args.article_cache_capacity,
Some(CacheCapacity::try_new(128_000_000).unwrap())
);
assert_eq!(args.article_cache_ttl_secs, Some(7200));
assert_eq!(args.store_article_bodies, Some(true));
assert_eq!(args.effective_ui_mode(), UiMode::Tui);
let legacy_args = CommonArgs::parse_from([
"nntp-proxy",
"--no-tui",
"--cache-capacity",
"256mb",
"--backend-strategy",
"weighted-round-robin",
"--cache-ttl",
"3600",
"--store-articles",
"false",
]);
assert_eq!(
legacy_args.backend_selection,
Some(BackendSelectionStrategy::WeightedRoundRobin)
);
assert_eq!(
legacy_args.article_cache_capacity,
Some(CacheCapacity::try_new(256_000_000).unwrap())
);
assert_eq!(legacy_args.article_cache_ttl_secs, Some(3600));
assert_eq!(legacy_args.store_article_bodies, Some(false));
assert_eq!(legacy_args.effective_ui_mode(), UiMode::Headless);
}
#[test]
fn test_common_args_parse_tui_shorthand() {
let args = CommonArgs::parse_from(["nntp-proxy", "--tui"]);
assert!(args.tui);
assert_eq!(args.effective_ui_mode(), UiMode::Tui);
}
#[test]
fn test_common_args_parse_headless_shorthand() {
let args = CommonArgs::parse_from(["nntp-proxy", "--headless"]);
assert!(args.headless);
assert_eq!(args.effective_ui_mode(), UiMode::Headless);
}
#[test]
fn test_common_args_rejects_duplicate_ui_selectors() {
assert!(CommonArgs::try_parse_from(["nntp-proxy", "--ui", "tui", "--tui"]).is_err());
assert!(CommonArgs::try_parse_from(["nntp-proxy", "--tui", "--headless"]).is_err());
}
#[test]
fn test_validate_runtime_mode_accepts_attach_client() {
let args = CommonArgs {
ui: UiMode::Tui,
tui_attach: Some("127.0.0.1:8119".parse().unwrap()),
..default_args()
};
args.validate_runtime_mode().unwrap();
}
#[test]
fn test_validate_runtime_mode_accepts_attach_with_tui_shorthand() {
let args = CommonArgs {
tui: true,
tui_attach: Some("127.0.0.1:8119".parse().unwrap()),
..default_args()
};
args.validate_runtime_mode().unwrap();
}
#[test]
fn test_validate_runtime_mode_rejects_attach_without_tui() {
let args = CommonArgs {
tui_attach: Some("127.0.0.1:8119".parse().unwrap()),
..default_args()
};
assert!(args.validate_runtime_mode().is_err());
}
#[test]
fn test_validate_runtime_mode_rejects_attach_and_listen() {
let args = CommonArgs {
ui: UiMode::Tui,
tui_attach: Some("127.0.0.1:8119".parse().unwrap()),
tui_listen: Some("127.0.0.1:8120".parse().unwrap()),
..default_args()
};
assert!(args.validate_runtime_mode().is_err());
}
#[test]
fn test_validate_runtime_mode_rejects_non_loopback_attach() {
let args = CommonArgs {
ui: UiMode::Tui,
tui_attach: Some("10.0.0.5:8119".parse().unwrap()),
..default_args()
};
assert!(args.validate_runtime_mode().is_err());
}
#[test]
fn test_validate_runtime_mode_accepts_tui_listen_with_headless_shorthand() {
let args = CommonArgs {
headless: true,
tui_listen: Some("127.0.0.1:8119".parse().unwrap()),
..default_args()
};
args.validate_runtime_mode().unwrap();
}
#[test]
fn test_validate_runtime_mode_rejects_tui_listen_with_local_tui() {
let args = CommonArgs {
ui: UiMode::Tui,
tui_listen: Some("127.0.0.1:8119".parse().unwrap()),
..default_args()
};
assert!(args.validate_runtime_mode().is_err());
}
#[test]
fn test_validate_dashboard_listen_rejects_same_socket() {
let args = CommonArgs {
tui_listen: Some("127.0.0.1:8119".parse().unwrap()),
..default_args()
};
assert!(
args.validate_dashboard_listen("127.0.0.1", Port::try_new(8119).unwrap())
.is_err()
);
}
#[test]
fn test_validate_dashboard_listen_allows_distinct_socket() {
let args = CommonArgs {
tui_listen: Some("127.0.0.1:8120".parse().unwrap()),
..default_args()
};
args.validate_dashboard_listen("127.0.0.1", Port::try_new(8119).unwrap())
.unwrap();
}
#[test]
fn test_validate_dashboard_listen_rejects_localhost_alias_collision() {
let args = CommonArgs {
tui_listen: Some("127.0.0.1:8119".parse().unwrap()),
..default_args()
};
assert!(
args.validate_dashboard_listen("localhost", Port::try_new(8119).unwrap())
.is_err()
);
}
#[test]
fn test_validate_dashboard_listen_allows_distinct_loopback_ips() {
let args = CommonArgs {
tui_listen: Some("[::1]:8119".parse().unwrap()),
..default_args()
};
args.validate_dashboard_listen("127.0.0.1", Port::try_new(8119).unwrap())
.unwrap();
}
#[test]
fn test_validate_dashboard_listen_rejects_non_loopback_bind() {
let args = CommonArgs {
tui_listen: Some("0.0.0.0:8119".parse().unwrap()),
..default_args()
};
assert!(
args.validate_dashboard_listen("127.0.0.1", Port::try_new(9120).unwrap())
.is_err()
);
}
#[test]
fn test_help_shows_tui_socket_format() {
let mut command = CommonArgs::command();
let mut help = Vec::new();
command.write_long_help(&mut help).unwrap();
let help = String::from_utf8(help).unwrap();
assert!(help.contains("--tui"));
assert!(help.contains("--headless"));
assert!(help.contains("--tui-listen <IP:PORT>"));
assert!(help.contains("--tui-attach <IP:PORT>"));
}
fn default_args() -> CommonArgs {
CommonArgs {
config: ConfigPath::new("config.toml").unwrap(),
ui: UiMode::Headless,
tui: false,
headless: false,
no_tui: false,
tui_listen: None,
tui_attach: None,
port: None,
host: None,
routing_mode: None,
backend_selection: None,
article_cache_capacity: None,
article_cache_ttl_secs: None,
store_article_bodies: None,
threads: None,
}
}
#[test]
fn test_apply_overrides_none_preserves_config() {
let args = default_args();
let mut config = Config::default();
config.proxy.backend_selection = BackendSelectionStrategy::WeightedRoundRobin;
config.proxy.routing_mode = RoutingMode::Stateful;
args.apply_overrides(&mut config);
assert_eq!(
config.proxy.backend_selection,
BackendSelectionStrategy::WeightedRoundRobin
);
assert_eq!(config.proxy.routing_mode, RoutingMode::Stateful);
}
#[test]
fn test_apply_overrides_routing() {
let args = CommonArgs {
routing_mode: Some(RoutingMode::PerCommand),
backend_selection: Some(BackendSelectionStrategy::LeastLoaded),
..default_args()
};
let mut config = Config::default();
args.apply_overrides(&mut config);
assert_eq!(config.routing.routing_mode, RoutingMode::PerCommand);
assert_eq!(
config.routing.backend_selection,
BackendSelectionStrategy::LeastLoaded
);
}
#[test]
fn test_apply_overrides_cache() {
let args = CommonArgs {
article_cache_capacity: Some(CacheCapacity::try_new(128 * 1024 * 1024).unwrap()),
article_cache_ttl_secs: Some(7200),
store_article_bodies: Some(false),
..default_args()
};
let mut config = Config {
cache: None, ..Config::default()
};
args.apply_overrides(&mut config);
let cache = config.cache.as_ref().unwrap();
assert_eq!(cache.article_cache_capacity.get(), 128 * 1024 * 1024);
assert_eq!(
cache.article_cache_ttl_secs,
crate::constants::duration_polyfill::from_hours(2)
);
assert!(!cache.store_article_bodies);
}
#[test]
fn test_apply_overrides_legacy_env_compat() {
let args = default_args();
let mut config = Config {
cache: None,
..Config::default()
};
args.apply_overrides_with_env(&mut config, |key| match key {
"NNTP_PROXY_CACHE_CAPACITY" => Some("128mb".to_string()),
"NNTP_PROXY_CACHE_TTL" => Some("7200".to_string()),
"NNTP_PROXY_CACHE_ARTICLES" => Some("false".to_string()),
_ => None,
});
let cache = config.cache.as_ref().unwrap();
assert_eq!(cache.article_cache_capacity.get(), 128_000_000);
assert_eq!(
cache.article_cache_ttl_secs,
crate::constants::duration_polyfill::from_hours(2)
);
assert!(!cache.store_article_bodies);
}
}