use std::path::PathBuf;
use std::time::Duration;
use crate::Result;
use crate::identity::ParticipantId;
use clap::Parser;
pub(crate) const SHUTDOWN_GRACE: Duration = Duration::from_millis(2000);
#[derive(Clone, Debug, Parser)]
#[command(
name = "phoxal-participant",
about = "Run one participant from an installed Phoxal bundle.",
long_about = None
)]
pub(crate) struct Launch {
#[arg(long, value_name = "ID", value_parser = parse_participant_id)]
pub(crate) participant_id: ParticipantId,
#[arg(long, value_name = "DIR")]
pub(crate) bundle_root: PathBuf,
#[arg(
long = "connect",
value_name = "ENDPOINT",
required = true,
value_parser = parse_connect_endpoint
)]
pub(crate) connect_endpoints: Vec<String>,
#[arg(long = "simulation")]
pub(crate) simulation: bool,
}
impl Launch {
pub(crate) fn parse() -> Result<Self> {
Self::try_parse().map_err(anyhow::Error::from)
}
}
#[allow(
dead_code,
reason = "the encoder is the host half of this contract; a participant decodes"
)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LaunchCommand {
participant_id: ParticipantId,
bundle_root: PathBuf,
connect_endpoints: Vec<String>,
simulation: bool,
}
#[allow(
dead_code,
reason = "the encoder is the host half of this contract; a participant decodes"
)]
impl LaunchCommand {
#[must_use]
pub fn new(participant_id: ParticipantId, bundle_root: impl Into<PathBuf>) -> Self {
Self {
participant_id,
bundle_root: bundle_root.into(),
connect_endpoints: Vec::new(),
simulation: false,
}
}
#[must_use]
pub fn connect(mut self, endpoint: impl Into<String>) -> Self {
self.connect_endpoints.push(endpoint.into());
self
}
#[must_use]
pub const fn simulation(mut self, simulation: bool) -> Self {
self.simulation = simulation;
self
}
#[must_use]
pub fn argv(&self) -> Vec<String> {
let mut argv = vec![
"--participant-id".to_owned(),
self.participant_id.as_str().to_owned(),
"--bundle-root".to_owned(),
self.bundle_root.display().to_string(),
];
for endpoint in &self.connect_endpoints {
argv.push("--connect".to_owned());
argv.push(endpoint.clone());
}
if self.simulation {
argv.push("--simulation".to_owned());
}
argv
}
}
fn parse_participant_id(value: &str) -> std::result::Result<ParticipantId, String> {
value
.parse()
.map_err(|error: crate::identity::ParticipantIdError| error.to_string())
}
fn parse_connect_endpoint(value: &str) -> std::result::Result<String, String> {
if value.is_empty()
|| value.trim() != value
|| value.bytes().any(|byte| byte.is_ascii_control())
{
return Err("connect endpoint must be non-empty and contain no surrounding whitespace or control characters".to_string());
}
Ok(value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, error::ErrorKind};
fn args() -> Vec<&'static str> {
vec![
"participant-bin",
"--participant-id",
"drive",
"--bundle-root",
"/var/lib/phoxal/bundle",
"--connect",
"tcp/router-a:7447",
]
}
#[test]
fn accepts_only_the_four_launch_facts() {
let launch = Launch::try_parse_from(args()).expect("valid launch argv");
assert_eq!(launch.participant_id.as_str(), "drive");
assert_eq!(launch.bundle_root, PathBuf::from("/var/lib/phoxal/bundle"));
assert_eq!(launch.connect_endpoints, ["tcp/router-a:7447"]);
assert!(
!launch.simulation,
"the host clock is the default; simulation is opted into"
);
}
#[test]
fn accepts_multiple_connect_endpoints_without_a_comma_encoding() {
let mut argv = args();
argv.extend(["--connect", "tcp/router-b:7447", "--simulation"]);
let launch = Launch::try_parse_from(argv).expect("valid repeated endpoints");
assert_eq!(
launch.connect_endpoints,
["tcp/router-a:7447", "tcp/router-b:7447"]
);
assert!(launch.simulation);
}
#[test]
fn missing_required_fields_fails_before_bundle_or_bus_work() {
let error = Launch::try_parse_from(["participant-bin"])
.expect_err("required launch fields must not have defaults");
assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument);
}
#[test]
fn a_malformed_participant_id_is_rejected() {
let mut invalid_participant = args();
invalid_participant[2] = "Drive";
assert!(Launch::try_parse_from(invalid_participant).is_err());
}
#[test]
fn the_retired_launch_facts_are_rejected_as_unknown_arguments() {
for retired in [
vec!["--execution-id", "10000000000000000000000000000001"],
vec!["--execution-origin", "7:42:9"],
vec!["--shutdown-grace-ms", "500"],
] {
let mut argv = args();
argv.extend(retired.iter().copied());
let error =
Launch::try_parse_from(argv).expect_err("a retired launch flag has no parser");
assert_eq!(error.kind(), ErrorKind::UnknownArgument, "{retired:?}");
}
}
#[test]
fn the_long_flag_set_is_exactly_the_four_launch_facts() {
let command = Launch::command();
let mut longs = command
.get_arguments()
.filter_map(clap::Arg::get_long)
.collect::<Vec<_>>();
longs.sort_unstable();
assert_eq!(
longs,
["bundle-root", "connect", "participant-id", "simulation"]
);
for argument in command.get_arguments() {
assert!(
argument
.get_all_aliases()
.is_none_or(|aliases| aliases.is_empty()),
"{} declares a parser alias",
argument.get_id()
);
assert!(
argument.get_short().is_none(),
"{} declares a short flag; the launch contract is long-only",
argument.get_id()
);
assert!(
argument
.get_all_short_aliases()
.is_none_or(|aliases| aliases.is_empty()),
"{} declares a short parser alias",
argument.get_id()
);
}
}
#[test]
fn empty_connect_endpoint_is_rejected() {
let mut argv = args();
argv[6] = "";
assert!(Launch::try_parse_from(argv).is_err());
}
#[test]
fn the_encoder_writes_exactly_what_the_decoder_accepts() {
let command = LaunchCommand::new(
ParticipantId::new("drive").expect("a valid participant id"),
"/var/lib/phoxal/bundle",
)
.connect("tcp/router-a:7447")
.connect("tcp/router-b:7447")
.simulation(true);
let argv = command.argv();
assert_eq!(
argv,
[
"--participant-id",
"drive",
"--bundle-root",
"/var/lib/phoxal/bundle",
"--connect",
"tcp/router-a:7447",
"--connect",
"tcp/router-b:7447",
"--simulation",
]
);
let launch = Launch::try_parse_from(
std::iter::once("participant-bin".to_owned()).chain(argv.iter().cloned()),
)
.expect("the encoder's argv parses");
assert_eq!(launch.participant_id.as_str(), "drive");
assert_eq!(launch.bundle_root, PathBuf::from("/var/lib/phoxal/bundle"));
assert_eq!(
launch.connect_endpoints,
["tcp/router-a:7447", "tcp/router-b:7447"]
);
assert!(launch.simulation);
}
#[test]
fn the_encoder_opts_into_simulation_rather_than_out_of_it() {
let argv = LaunchCommand::new(
ParticipantId::new("drive").expect("a valid participant id"),
"/var/lib/phoxal/bundle",
)
.connect("tcp/router-a:7447")
.argv();
assert!(!argv.contains(&"--simulation".to_owned()), "{argv:?}");
let launch =
Launch::try_parse_from(std::iter::once("participant-bin".to_owned()).chain(argv))
.expect("the encoder's argv parses");
assert!(!launch.simulation);
}
#[test]
fn every_process_field_is_clap_only_and_has_no_environment_binding() {
let command = Launch::command();
for argument in command.get_arguments() {
assert!(
argument.get_env().is_none(),
"{} unexpectedly reads an environment variable",
argument.get_id()
);
}
}
}