From a6b266697a8b7ae4876a1291414ff5375b81112a Mon Sep 17 00:00:00 2001 From: Celestial Date: Fri, 13 Feb 2026 18:09:56 +0100 Subject: [PATCH] feat(architecture): implement phase 1 command mapping seam --- .../ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md | 6 + src/adapters/cli/mapper.rs | 168 ++++++++++++++++++ src/adapters/cli/mod.rs | 1 + src/adapters/mod.rs | 1 + src/application/commands.rs | 143 +++++++++++++++ src/application/mod.rs | 1 + src/domain/mod.rs | 1 + src/domain/run.rs | 97 ++++++++++ src/entry/plan/build.rs | 21 ++- src/entry/plan/execute.rs | 49 +++-- src/entry/plan/types.rs | 37 +--- src/main.rs | 3 + 12 files changed, 478 insertions(+), 50 deletions(-) create mode 100644 src/adapters/cli/mapper.rs create mode 100644 src/adapters/cli/mod.rs create mode 100644 src/adapters/mod.rs create mode 100644 src/application/commands.rs create mode 100644 src/application/mod.rs create mode 100644 src/domain/mod.rs create mode 100644 src/domain/run.rs diff --git a/docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md b/docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md index b58decb..1652f1e 100644 --- a/docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md +++ b/docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md @@ -264,6 +264,12 @@ Phase 0 artifacts (implemented): Exit criteria: - `entry` calls use-case commands, not raw `TesterArgs` (except adapter boundary). +Phase 1 artifacts (implemented): +- Domain run model: `src/domain/run.rs` +- Application commands per mode: `src/application/commands.rs` +- CLI anti-corruption mapper: `src/adapters/cli/mapper.rs` +- Entry plan command wiring: `src/entry/plan/types.rs`, `src/entry/plan/build.rs`, `src/entry/plan/execute.rs` + ### Phase 2: Config decoupling (1-2 weeks) 1. Replace `config::apply_config(&mut TesterArgs, ...)` with `ConfigOverrides` builder. 2. Merge order defined in one place: `CLI > Config > Preset defaults`. diff --git a/src/adapters/cli/mapper.rs b/src/adapters/cli/mapper.rs new file mode 100644 index 0000000..7a35c07 --- /dev/null +++ b/src/adapters/cli/mapper.rs @@ -0,0 +1,168 @@ +use std::collections::BTreeMap; + +use crate::application::commands::{ + AgentRunCommand, ControllerRunCommand, LocalRunCommand, ReplayRunCommand, ServiceCommand, +}; +use crate::args::{ + LoadMode as CliLoadMode, Protocol as CliProtocol, Scenario as CliScenario, TesterArgs, +}; +use crate::config::types::ScenarioConfig; +use crate::domain::run::{LoadMode, ProtocolKind, RunConfig, Scenario}; +use crate::error::{AppError, AppResult, ValidationError}; + +pub(crate) fn to_local_run_command(mut args: TesterArgs) -> AppResult { + if args.url.is_none() && args.scenario.is_none() { + tracing::error!("Missing URL (set --url or provide in config)."); + return Err(AppError::validation(ValidationError::MissingUrl)); + } + args.distributed_stream_summaries = false; + let run_config = to_run_config(&args); + Ok(LocalRunCommand::new(run_config, args)) +} + +pub(crate) fn to_replay_run_command(args: TesterArgs) -> ReplayRunCommand { + let run_config = to_run_config(&args); + ReplayRunCommand::new(run_config, args) +} + +pub(crate) const fn to_service_command(args: TesterArgs) -> ServiceCommand { + ServiceCommand::new(args) +} + +pub(crate) fn to_controller_run_command( + args: TesterArgs, + scenarios: Option>, +) -> ControllerRunCommand { + let run_config = to_run_config(&args); + ControllerRunCommand::new(run_config, args, scenarios) +} + +pub(crate) fn to_agent_run_command(args: TesterArgs) -> AgentRunCommand { + let run_config = to_run_config(&args); + AgentRunCommand::new(run_config, args) +} + +fn to_run_config(args: &TesterArgs) -> RunConfig { + RunConfig { + protocol: map_protocol(args.protocol), + load_mode: map_load_mode(args.load_mode), + target_url: args.url.clone(), + scenario: args.scenario.as_ref().map(map_scenario), + } +} + +const fn map_protocol(protocol: CliProtocol) -> ProtocolKind { + match protocol { + CliProtocol::Http => ProtocolKind::Http, + CliProtocol::GrpcUnary => ProtocolKind::GrpcUnary, + CliProtocol::GrpcStreaming => ProtocolKind::GrpcStreaming, + CliProtocol::Websocket => ProtocolKind::Websocket, + CliProtocol::Tcp => ProtocolKind::Tcp, + CliProtocol::Udp => ProtocolKind::Udp, + CliProtocol::Quic => ProtocolKind::Quic, + CliProtocol::Mqtt => ProtocolKind::Mqtt, + CliProtocol::Enet => ProtocolKind::Enet, + CliProtocol::Kcp => ProtocolKind::Kcp, + CliProtocol::Raknet => ProtocolKind::Raknet, + } +} + +const fn map_load_mode(load_mode: CliLoadMode) -> LoadMode { + match load_mode { + CliLoadMode::Arrival => LoadMode::Arrival, + CliLoadMode::Step => LoadMode::Step, + CliLoadMode::Ramp => LoadMode::Ramp, + CliLoadMode::Jitter => LoadMode::Jitter, + CliLoadMode::Burst => LoadMode::Burst, + CliLoadMode::Soak => LoadMode::Soak, + } +} + +fn map_scenario(scenario: &CliScenario) -> Scenario { + Scenario { + base_url: scenario.base_url.clone(), + vars_count: scenario.vars.len(), + step_count: scenario.steps.len(), + } +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use crate::args::TesterArgs; + use crate::domain::run::{LoadMode, ProtocolKind}; + use crate::error::{AppError, ValidationError}; + + use super::to_local_run_command; + + #[test] + fn local_mapper_requires_url_or_scenario() { + let args_result = TesterArgs::try_parse_from(["strest"]); + assert!( + args_result.is_ok(), + "Expected CLI parsing to succeed without URL for mapper validation" + ); + let args = if let Ok(args) = args_result { + args + } else { + return; + }; + + let mapped = to_local_run_command(args); + assert!( + mapped.is_err(), + "Expected local command mapper to reject missing URL and scenario" + ); + let err = if let Err(err) = mapped { + err + } else { + return; + }; + assert!(matches!( + err, + AppError::Validation(ValidationError::MissingUrl) + )); + } + + #[test] + fn local_mapper_builds_domain_run_config() { + let args_result = TesterArgs::try_parse_from([ + "strest", + "--url", + "grpc://127.0.0.1:50051/test.Service/Method", + "--protocol", + "grpc-unary", + "--load-mode", + "arrival", + ]); + assert!( + args_result.is_ok(), + "Expected CLI parsing to succeed for local mapper test" + ); + let args = if let Ok(args) = args_result { + args + } else { + return; + }; + + let mapped = to_local_run_command(args); + assert!( + mapped.is_ok(), + "Expected local command mapping to succeed for valid arguments" + ); + let command = if let Ok(command) = mapped { + command + } else { + return; + }; + + assert_eq!(command.run_config().protocol, ProtocolKind::GrpcUnary); + assert_eq!(command.run_config().load_mode, LoadMode::Arrival); + assert_eq!( + command.run_config().target_url.as_deref(), + Some("grpc://127.0.0.1:50051/test.Service/Method") + ); + assert_eq!(command.run_config().scenario_step_count(), 0); + } +} diff --git a/src/adapters/cli/mod.rs b/src/adapters/cli/mod.rs new file mode 100644 index 0000000..19b3f82 --- /dev/null +++ b/src/adapters/cli/mod.rs @@ -0,0 +1 @@ +pub(crate) mod mapper; diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs new file mode 100644 index 0000000..42e8dd6 --- /dev/null +++ b/src/adapters/mod.rs @@ -0,0 +1 @@ +pub(crate) mod cli; diff --git a/src/application/commands.rs b/src/application/commands.rs new file mode 100644 index 0000000..698b951 --- /dev/null +++ b/src/application/commands.rs @@ -0,0 +1,143 @@ +use std::collections::BTreeMap; + +use crate::args::TesterArgs; +use crate::config::types::ScenarioConfig; +use crate::domain::run::RunConfig; + +#[derive(Debug)] +pub(crate) struct LocalRunCommand { + run_config: RunConfig, + args: TesterArgs, +} + +impl LocalRunCommand { + #[must_use] + pub(crate) const fn new(run_config: RunConfig, args: TesterArgs) -> Self { + Self { run_config, args } + } + + #[must_use] + pub(crate) const fn run_config(&self) -> &RunConfig { + &self.run_config + } + + #[must_use] + pub(crate) const fn no_color(&self) -> bool { + self.args.no_color + } + + #[must_use] + pub(crate) fn into_args(self) -> TesterArgs { + self.args + } +} + +#[derive(Debug)] +pub(crate) struct ReplayRunCommand { + run_config: RunConfig, + args: TesterArgs, +} + +impl ReplayRunCommand { + #[must_use] + pub(crate) const fn new(run_config: RunConfig, args: TesterArgs) -> Self { + Self { run_config, args } + } + + #[must_use] + pub(crate) const fn run_config(&self) -> &RunConfig { + &self.run_config + } + + #[must_use] + pub(crate) const fn no_color(&self) -> bool { + self.args.no_color + } + + #[must_use] + pub(crate) const fn as_args(&self) -> &TesterArgs { + &self.args + } +} + +#[derive(Debug)] +pub(crate) struct ServiceCommand { + args: TesterArgs, +} + +impl ServiceCommand { + #[must_use] + pub(crate) const fn new(args: TesterArgs) -> Self { + Self { args } + } + + #[must_use] + pub(crate) const fn as_args(&self) -> &TesterArgs { + &self.args + } +} + +#[derive(Debug)] +pub(crate) struct ControllerRunCommand { + run_config: RunConfig, + args: TesterArgs, + scenarios: Option>, +} + +impl ControllerRunCommand { + #[must_use] + pub(crate) const fn new( + run_config: RunConfig, + args: TesterArgs, + scenarios: Option>, + ) -> Self { + Self { + run_config, + args, + scenarios, + } + } + + #[must_use] + pub(crate) const fn run_config(&self) -> &RunConfig { + &self.run_config + } + + #[must_use] + pub(crate) const fn no_color(&self) -> bool { + self.args.no_color + } + + #[must_use] + pub(crate) fn into_parts(self) -> (TesterArgs, Option>) { + (self.args, self.scenarios) + } +} + +#[derive(Debug)] +pub(crate) struct AgentRunCommand { + run_config: RunConfig, + args: TesterArgs, +} + +impl AgentRunCommand { + #[must_use] + pub(crate) const fn new(run_config: RunConfig, args: TesterArgs) -> Self { + Self { run_config, args } + } + + #[must_use] + pub(crate) const fn run_config(&self) -> &RunConfig { + &self.run_config + } + + #[must_use] + pub(crate) const fn no_color(&self) -> bool { + self.args.no_color + } + + #[must_use] + pub(crate) fn into_args(self) -> TesterArgs { + self.args + } +} diff --git a/src/application/mod.rs b/src/application/mod.rs new file mode 100644 index 0000000..b0b53bb --- /dev/null +++ b/src/application/mod.rs @@ -0,0 +1 @@ +pub(crate) mod commands; diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..bcb3b38 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1 @@ +pub(crate) mod run; diff --git a/src/domain/run.rs b/src/domain/run.rs new file mode 100644 index 0000000..99aa039 --- /dev/null +++ b/src/domain/run.rs @@ -0,0 +1,97 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProtocolKind { + Http, + GrpcUnary, + GrpcStreaming, + Websocket, + Tcp, + Udp, + Quic, + Mqtt, + Enet, + Kcp, + Raknet, +} + +impl ProtocolKind { + #[must_use] + pub(crate) const fn as_str(self) -> &'static str { + match self { + ProtocolKind::Http => "http", + ProtocolKind::GrpcUnary => "grpc-unary", + ProtocolKind::GrpcStreaming => "grpc-streaming", + ProtocolKind::Websocket => "websocket", + ProtocolKind::Tcp => "tcp", + ProtocolKind::Udp => "udp", + ProtocolKind::Quic => "quic", + ProtocolKind::Mqtt => "mqtt", + ProtocolKind::Enet => "enet", + ProtocolKind::Kcp => "kcp", + ProtocolKind::Raknet => "raknet", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LoadMode { + Arrival, + Step, + Ramp, + Jitter, + Burst, + Soak, +} + +impl LoadMode { + #[must_use] + pub(crate) const fn as_str(self) -> &'static str { + match self { + LoadMode::Arrival => "arrival", + LoadMode::Step => "step", + LoadMode::Ramp => "ramp", + LoadMode::Jitter => "jitter", + LoadMode::Burst => "burst", + LoadMode::Soak => "soak", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Scenario { + pub(crate) base_url: Option, + pub(crate) vars_count: usize, + pub(crate) step_count: usize, +} + +#[derive(Debug, Clone)] +pub(crate) struct RunConfig { + pub(crate) protocol: ProtocolKind, + pub(crate) load_mode: LoadMode, + pub(crate) target_url: Option, + pub(crate) scenario: Option, +} + +impl RunConfig { + #[must_use] + pub(crate) fn scenario_step_count(&self) -> usize { + self.scenario + .as_ref() + .map(|scenario| scenario.step_count) + .unwrap_or(0) + } + + #[must_use] + pub(crate) fn scenario_vars_count(&self) -> usize { + self.scenario + .as_ref() + .map(|scenario| scenario.vars_count) + .unwrap_or(0) + } + + #[must_use] + pub(crate) fn scenario_base_url(&self) -> Option<&str> { + self.scenario + .as_ref() + .and_then(|scenario| scenario.base_url.as_deref()) + } +} diff --git a/src/entry/plan/build.rs b/src/entry/plan/build.rs index 0ac56ab..26f7b80 100644 --- a/src/entry/plan/build.rs +++ b/src/entry/plan/build.rs @@ -2,6 +2,10 @@ use std::collections::BTreeMap; use clap::ArgMatches; +use crate::adapters::cli::mapper::{ + to_agent_run_command, to_controller_run_command, to_local_run_command, to_replay_run_command, + to_service_command, +}; use crate::args::{Command, LoadMode, OutputFormat, TesterArgs}; use crate::config::types::ScenarioConfig; #[cfg(not(feature = "wasm"))] @@ -9,7 +13,7 @@ use crate::error::ScriptError; use crate::error::{AppError, AppResult, ValidationError}; use crate::protocol::protocol_registry; -use super::types::{DumpUrlsPlan, LocalArgs, RunPlan}; +use super::types::{DumpUrlsPlan, RunPlan}; /// Only one shard is allowed when DB logging is enabled. const SINGLE_LOG_SHARD: usize = 1; @@ -63,7 +67,7 @@ pub(crate) fn build_plan(mut args: TesterArgs, matches: &ArgMatches) -> AppResul } if args.replay { - return Ok(RunPlan::Replay(args)); + return Ok(RunPlan::Replay(to_replay_run_command(args))); } let scenario_registry = apply_config(&mut args, matches)?; @@ -84,7 +88,7 @@ pub(crate) fn build_plan(mut args: TesterArgs, matches: &ArgMatches) -> AppResul } if args.install_service || args.uninstall_service { - return Ok(RunPlan::Service(args)); + return Ok(RunPlan::Service(to_service_command(args))); } if args.script.is_some() && args.scenario.is_some() { @@ -104,10 +108,10 @@ pub(crate) fn build_plan(mut args: TesterArgs, matches: &ArgMatches) -> AppResul } if args.controller_listen.is_some() { - return Ok(RunPlan::Controller { + return Ok(RunPlan::Controller(to_controller_run_command( args, - scenarios: scenario_registry, - }); + scenario_registry, + ))); } if args.no_ua && !args.authorized { @@ -120,11 +124,10 @@ pub(crate) fn build_plan(mut args: TesterArgs, matches: &ArgMatches) -> AppResul } if args.agent_join.is_some() { - return Ok(RunPlan::Agent(args)); + return Ok(RunPlan::Agent(to_agent_run_command(args))); } - let local_args = LocalArgs::new(args)?; - Ok(RunPlan::Local(local_args)) + Ok(RunPlan::Local(to_local_run_command(args)?)) } fn apply_config( diff --git a/src/entry/plan/execute.rs b/src/entry/plan/execute.rs index aeb8de5..a3deeaf 100644 --- a/src/entry/plan/execute.rs +++ b/src/entry/plan/execute.rs @@ -2,6 +2,7 @@ use rand::distributions::Distribution; use rand::thread_rng; use crate::app::{self, run_cleanup, run_compare, run_local, run_replay}; +use crate::domain::run::RunConfig; use crate::error::{AppError, AppResult, ValidationError}; use crate::system::banner; @@ -11,30 +12,35 @@ pub(crate) async fn execute_plan(plan: RunPlan) -> AppResult<()> { match plan { RunPlan::Cleanup(cleanup_args) => run_cleanup(&cleanup_args).await, RunPlan::Compare(compare_args) => run_compare(&compare_args).await, - RunPlan::Replay(args) => { - banner::print_cli_banner(args.no_color); + RunPlan::Replay(command) => { + log_run_command("replay", command.run_config()); + banner::print_cli_banner(command.no_color()); println!(); - run_replay(&args).await + run_replay(command.as_args()).await } RunPlan::DumpUrls(plan) => dump_urls(plan), - RunPlan::Service(args) => { - crate::service::handle_service_action(&args)?; + RunPlan::Service(command) => { + crate::service::handle_service_action(command.as_args())?; Ok(()) } - RunPlan::Controller { args, scenarios } => { - banner::print_cli_banner(args.no_color); + RunPlan::Controller(command) => { + log_run_command("controller", command.run_config()); + banner::print_cli_banner(command.no_color()); println!(); + let (args, scenarios) = command.into_parts(); crate::distributed::run_controller(&args, scenarios).await } - RunPlan::Agent(args) => { - banner::print_cli_banner(args.no_color); + RunPlan::Agent(command) => { + log_run_command("agent", command.run_config()); + banner::print_cli_banner(command.no_color()); println!(); - crate::distributed::run_agent(args).await + crate::distributed::run_agent(command.into_args()).await } - RunPlan::Local(local) => { - banner::print_cli_banner(local.args.no_color); + RunPlan::Local(command) => { + log_run_command("local", command.run_config()); + banner::print_cli_banner(command.no_color()); println!(); - let outcome = match run_local(local.args, None, None).await { + let outcome = match run_local(command.into_args(), None, None).await { Ok(outcome) => outcome, Err(AppError::Validation(ValidationError::RunCancelled)) => return Ok(()), Err(err) => return Err(err), @@ -48,6 +54,23 @@ pub(crate) async fn execute_plan(plan: RunPlan) -> AppResult<()> { } } +fn log_run_command(kind: &str, run_config: &RunConfig) { + let target = run_config.target_url.as_deref().unwrap_or(""); + let scenario_steps = run_config.scenario_step_count(); + let scenario_vars = run_config.scenario_vars_count(); + let scenario_base_url = run_config.scenario_base_url().unwrap_or(""); + tracing::debug!( + "Executing {} command: protocol={}, load_mode={}, target={}, scenario_steps={}, scenario_vars={}, scenario_base_url={}", + kind, + run_config.protocol.as_str(), + run_config.load_mode.as_str(), + target, + scenario_steps, + scenario_vars, + scenario_base_url + ); +} + fn dump_urls(plan: DumpUrlsPlan) -> AppResult<()> { let regex = rand_regex::Regex::compile(&plan.pattern, plan.max_repeat).map_err(|err| { AppError::validation(ValidationError::InvalidRandRegex { diff --git a/src/entry/plan/types.rs b/src/entry/plan/types.rs index 9c96b7a..fe0baa0 100644 --- a/src/entry/plan/types.rs +++ b/src/entry/plan/types.rs @@ -1,8 +1,7 @@ -use std::collections::BTreeMap; - -use crate::args::{CleanupArgs, CompareArgs, TesterArgs}; -use crate::config::types::ScenarioConfig; -use crate::error::{AppError, AppResult, ValidationError}; +use crate::application::commands::{ + AgentRunCommand, ControllerRunCommand, LocalRunCommand, ReplayRunCommand, ServiceCommand, +}; +use crate::args::{CleanupArgs, CompareArgs}; pub(in crate::entry) struct DumpUrlsPlan { pub(super) pattern: String, @@ -10,31 +9,13 @@ pub(in crate::entry) struct DumpUrlsPlan { pub(super) max_repeat: u32, } -pub(in crate::entry) struct LocalArgs { - pub(super) args: TesterArgs, -} - -impl LocalArgs { - pub(super) fn new(mut args: TesterArgs) -> AppResult { - if args.url.is_none() && args.scenario.is_none() { - tracing::error!("Missing URL (set --url or provide in config)."); - return Err(AppError::validation(ValidationError::MissingUrl)); - } - args.distributed_stream_summaries = false; - Ok(Self { args }) - } -} - pub(in crate::entry) enum RunPlan { Cleanup(CleanupArgs), Compare(CompareArgs), - Replay(TesterArgs), + Replay(ReplayRunCommand), DumpUrls(DumpUrlsPlan), - Service(TesterArgs), - Controller { - args: TesterArgs, - scenarios: Option>, - }, - Agent(TesterArgs), - Local(LocalArgs), + Service(ServiceCommand), + Controller(ControllerRunCommand), + Agent(AgentRunCommand), + Local(LocalRunCommand), } diff --git a/src/main.rs b/src/main.rs index 0466a03..058bd95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,11 @@ +mod adapters; mod app; +mod application; mod args; mod charts; mod config; mod distributed; +mod domain; mod entry; mod error; mod http;