use clap::Parser;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(clap::Subcommand, Debug, Clone, PartialEq, Eq)]
pub enum ServiceCommand {
Install,
Uninstall,
Start,
Stop,
Status,
}
#[derive(Parser, Debug)]
#[command(
name = "opcda-bridge-gateway",
about = "OPC DA bridge gateway",
version
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<ServiceCommand>,
#[arg(long, value_name = "PATH")]
pub config: Option<PathBuf>,
#[arg(long, env = "OPC_BRIDGE_PORT")]
pub port: Option<u16>,
#[arg(long, env = "RUST_LOG")]
pub log_level: Option<String>,
#[arg(long, value_name = "PATH")]
pub log_dir: Option<PathBuf>,
#[arg(long)]
pub log_format: Option<String>,
#[arg(long)]
pub log_rotation: Option<String>,
}
#[derive(Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct GatewayConfig {
pub port: Option<u16>,
#[serde(default)]
pub log: LogConfig,
}
#[derive(Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct LogConfig {
pub level: Option<String>,
pub dir: Option<String>,
pub format: Option<String>,
pub rotation: Option<String>,
}
pub fn config_path_from_exe(exe_path: &Path) -> PathBuf {
exe_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("opcda-bridge-gateway.toml")
}
pub fn load_config_file(path: &Path, missing_is_error: bool) -> anyhow::Result<GatewayConfig> {
match std::fs::read_to_string(path) {
Ok(contents) => toml::from_str(&contents)
.map_err(|e| anyhow::anyhow!("failed to parse config file {}: {e}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !missing_is_error => {
Ok(GatewayConfig::default())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err(anyhow::anyhow!("config file not found: {}", path.display()))
}
Err(e) => Err(anyhow::anyhow!(
"failed to read config file {}: {e}",
path.display()
)),
}
}
pub fn load_config(explicit_path: Option<&Path>) -> anyhow::Result<GatewayConfig> {
match explicit_path {
Some(path) => load_config_file(path, true),
None => {
let exe = std::env::current_exe().expect("failed to resolve current executable path");
load_config_file(&config_path_from_exe(&exe), false)
}
}
}
pub fn resolve_port(cli_port: Option<u16>, config: &GatewayConfig) -> u16 {
cli_port
.or(config.port)
.unwrap_or(opcda_bridge_proto::DEFAULT_BRIDGE_PORT)
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::io::Write;
#[test]
fn test_config_path_from_exe_with_parent() {
let path = config_path_from_exe(Path::new("/usr/local/bin/opcda-bridge-gateway"));
assert_eq!(
path,
PathBuf::from("/usr/local/bin/opcda-bridge-gateway.toml")
);
}
#[test]
fn test_config_path_from_exe_no_parent() {
let path = config_path_from_exe(Path::new("/"));
assert_eq!(path, PathBuf::from("./opcda-bridge-gateway.toml"));
}
#[test]
fn test_load_config_file_valid() {
let mut file = tempfile::NamedTempFile::new().unwrap();
writeln!(file, "port = 8080\n[log]\nlevel = \"debug\"").unwrap();
let config = load_config_file(file.path(), true).unwrap();
assert_eq!(config.port, Some(8080));
assert_eq!(config.log.level, Some("debug".to_string()));
}
#[test]
fn test_load_config_file_empty_is_all_defaults() {
let file = tempfile::NamedTempFile::new().unwrap();
let config = load_config_file(file.path(), true).unwrap();
assert_eq!(config, GatewayConfig::default());
}
#[test]
fn test_load_config_file_malformed() {
let mut file = tempfile::NamedTempFile::new().unwrap();
writeln!(file, "port = \"not a number\"").unwrap();
let err = load_config_file(file.path(), true).unwrap_err();
assert!(err.to_string().contains("failed to parse config file"));
}
#[test]
fn test_load_config_file_missing_not_error() {
let config =
load_config_file(Path::new("/nonexistent/opcda-bridge-gateway.toml"), false).unwrap();
assert_eq!(config, GatewayConfig::default());
}
#[test]
fn test_load_config_file_missing_is_error() {
let err = load_config_file(Path::new("/nonexistent/opcda-bridge-gateway.toml"), true)
.unwrap_err();
assert!(err.to_string().contains("config file not found"));
}
#[test]
fn test_load_config_file_generic_io_error() {
let dir = tempfile::tempdir().unwrap();
let err = load_config_file(dir.path(), true).unwrap_err();
assert!(err.to_string().contains("failed to read config file"));
}
#[test]
fn test_load_config_explicit_path() {
let mut file = tempfile::NamedTempFile::new().unwrap();
writeln!(file, "port = 9000").unwrap();
let config = load_config(Some(file.path())).unwrap();
assert_eq!(config.port, Some(9000));
}
#[test]
fn test_load_config_explicit_path_missing_errors() {
let err = load_config(Some(Path::new("/nonexistent/gateway.toml"))).unwrap_err();
assert!(err.to_string().contains("config file not found"));
}
#[test]
fn test_load_config_default_discovery() {
let config = load_config(None).unwrap();
assert_eq!(config, GatewayConfig::default());
}
#[test]
fn test_previous_gateway_config_fixture_remains_compatible() {
let config: GatewayConfig =
toml::from_str(include_str!("../tests/fixtures/gateway-v0.1.toml")).unwrap();
assert_eq!(config.port, Some(7700));
assert_eq!(config.log.level.as_deref(), Some("debug"));
}
#[test]
fn test_resolve_port_cli_wins() {
let config = GatewayConfig {
port: Some(1111),
log: LogConfig::default(),
};
assert_eq!(resolve_port(Some(2222), &config), 2222);
}
#[test]
fn test_resolve_port_config_wins_over_default() {
let config = GatewayConfig {
port: Some(1111),
log: LogConfig::default(),
};
assert_eq!(resolve_port(None, &config), 1111);
}
#[test]
fn test_resolve_port_default() {
assert_eq!(
resolve_port(None, &GatewayConfig::default()),
opcda_bridge_proto::DEFAULT_BRIDGE_PORT
);
}
#[test]
fn test_cli_parses_config_flag() {
let cli = Cli::try_parse_from(["opcda-bridge-gateway", "--config", "custom.toml"]).unwrap();
assert_eq!(cli.config, Some(PathBuf::from("custom.toml")));
assert_eq!(cli.port, None);
}
#[test]
fn test_cli_parses_port_flag() {
let cli = Cli::try_parse_from(["opcda-bridge-gateway", "--port", "9090"]).unwrap();
assert_eq!(cli.port, Some(9090));
}
#[test]
fn test_cli_defaults_to_none() {
let cli = Cli::try_parse_from(["opcda-bridge-gateway"]).unwrap();
assert_eq!(cli.command, None);
assert_eq!(cli.config, None);
assert_eq!(cli.port, None);
assert_eq!(cli.log_level, None);
assert_eq!(cli.log_dir, None);
assert_eq!(cli.log_format, None);
assert_eq!(cli.log_rotation, None);
}
#[test]
fn test_cli_parses_service_subcommands() {
for (arg, expected) in [
("install", ServiceCommand::Install),
("uninstall", ServiceCommand::Uninstall),
("start", ServiceCommand::Start),
("stop", ServiceCommand::Stop),
("status", ServiceCommand::Status),
] {
let cli = Cli::try_parse_from(["opcda-bridge-gateway", arg]).unwrap();
assert_eq!(cli.command, Some(expected));
}
}
#[test]
fn test_cli_flags_before_subcommand_apply_to_install() {
let cli = Cli::try_parse_from([
"opcda-bridge-gateway",
"--port",
"7700",
"--log-dir",
"/var/log/opcda",
"install",
])
.unwrap();
assert_eq!(cli.command, Some(ServiceCommand::Install));
assert_eq!(cli.port, Some(7700));
assert_eq!(cli.log_dir, Some(PathBuf::from("/var/log/opcda")));
}
#[test]
fn test_cli_rejects_unknown_subcommand() {
let err = Cli::try_parse_from(["opcda-bridge-gateway", "bogus"])
.err()
.unwrap();
assert_eq!(
err.kind(),
clap::error::ErrorKind::InvalidSubcommand,
"unrecognized \"bogus\" positional argument should fail to parse as a subcommand, not be silently accepted"
);
}
#[test]
fn test_cli_parses_log_flags() {
let cli = Cli::try_parse_from([
"opcda-bridge-gateway",
"--log-level",
"debug",
"--log-dir",
"/tmp/logs",
"--log-format",
"json",
"--log-rotation",
"hourly",
])
.unwrap();
assert_eq!(cli.log_level, Some("debug".to_string()));
assert_eq!(cli.log_dir, Some(PathBuf::from("/tmp/logs")));
assert_eq!(cli.log_format, Some("json".to_string()));
assert_eq!(cli.log_rotation, Some("hourly".to_string()));
}
#[test]
fn test_cli_version_flag() {
let err = Cli::try_parse_from(["opcda-bridge-gateway", "--version"])
.err()
.unwrap();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
}
proptest::proptest! {
#[test]
fn prop_gateway_config_toml_round_trip(
port in proptest::option::of(any::<u16>()),
level in proptest::option::of("[a-zA-Z0-9=,._-]{0,32}"),
dir in proptest::option::of("[a-zA-Z0-9:/._-]{0,32}"),
format in proptest::option::of("[a-zA-Z]{0,12}"),
rotation in proptest::option::of("[a-zA-Z]{0,12}"),
) {
let original = GatewayConfig {
port,
log: LogConfig {
level,
dir,
format,
rotation,
},
};
let encoded = toml::to_string(&original).unwrap();
let decoded: GatewayConfig = toml::from_str(&encoded).unwrap();
prop_assert_eq!(decoded, original);
}
#[test]
fn prop_malformed_gateway_toml_never_panics(input in any::<String>()) {
let _ = toml::from_str::<GatewayConfig>(&input);
}
}
}