use std::path::PathBuf;
use anyhow::Context;
use clap::{CommandFactory, FromArgMatches};
use serde::{Deserialize, Serialize};
use crate::bus::{ExecutionId, ProducerId};
use crate::participant::clock::ExecutionOrigin;
pub const DEFAULT_SHUTDOWN_GRACE_MS: u64 = 2000;
pub mod env {
pub const PARTICIPANT_ID: &str = "PHOXAL_PARTICIPANT_ID";
pub const EXECUTION_ID: &str = "PHOXAL_EXECUTION_ID";
pub const PRODUCER_ID: &str = "PHOXAL_PRODUCER_ID";
pub const EXECUTION_ORIGIN: &str = "PHOXAL_EXECUTION_ORIGIN";
pub const ROBOT_ID: &str = "PHOXAL_ROBOT_ID";
pub const NAMESPACE: &str = "PHOXAL_NAMESPACE";
pub const ROBOT_ROOT: &str = "PHOXAL_ROBOT_ROOT";
pub const COMPONENT_INSTANCE: &str = "PHOXAL_COMPONENT_INSTANCE";
pub const CONNECT: &str = "PHOXAL_CONNECT";
pub const CONFIG: &str = "PHOXAL_CONFIG";
pub const CLOCK: &str = "PHOXAL_CLOCK";
pub const ALL: &[&str] = &[
PARTICIPANT_ID,
EXECUTION_ID,
PRODUCER_ID,
EXECUTION_ORIGIN,
ROBOT_ID,
NAMESPACE,
ROBOT_ROOT,
COMPONENT_INSTANCE,
CONNECT,
CONFIG,
CLOCK,
];
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ParticipantLaunch {
pub participant_id: String,
pub execution: ExecutionId,
pub producer: ProducerId,
#[serde(default, with = "origin_serde")]
pub execution_origin: Option<ExecutionOrigin>,
pub namespace: String,
pub robot_id: String,
#[serde(default)]
pub bus: BusProfile,
#[serde(default)]
pub clock: ClockMode,
#[serde(default)]
pub config: Option<serde_json::Value>,
#[serde(default)]
pub robot_root: Option<PathBuf>,
#[serde(default)]
pub component_instance: Option<String>,
#[serde(default = "default_grace")]
pub shutdown_grace_ms: u64,
}
fn default_grace() -> u64 {
DEFAULT_SHUTDOWN_GRACE_MS
}
impl ParticipantLaunch {
pub fn local(participant_id: impl Into<String>, robot_id: impl Into<String>) -> Self {
ParticipantLaunch {
participant_id: participant_id.into(),
execution: ExecutionId::mint(),
producer: ProducerId::mint(),
execution_origin: None,
namespace: "dev".to_string(),
robot_id: robot_id.into(),
bus: BusProfile::default(),
clock: ClockMode::Real,
config: None,
robot_root: None,
component_instance: None,
shutdown_grace_ms: DEFAULT_SHUTDOWN_GRACE_MS,
}
}
pub fn in_execution(mut self, execution: ExecutionId) -> Self {
self.execution = execution;
self
}
pub fn with_execution_origin(mut self, origin: ExecutionOrigin) -> Self {
self.execution_origin = Some(origin);
self
}
}
mod origin_serde {
use super::ExecutionOrigin;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub(super) fn serialize<S: Serializer>(
value: &Option<ExecutionOrigin>,
serializer: S,
) -> Result<S::Ok, S::Error> {
value.map(ExecutionOrigin::encode).serialize(serializer)
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<ExecutionOrigin>, D::Error> {
let Some(rendered) = Option::<String>::deserialize(deserializer)? else {
return Ok(None);
};
ExecutionOrigin::decode(&rendered).map(Some).ok_or_else(|| {
serde::de::Error::custom(format!("malformed execution origin '{rendered}'"))
})
}
}
#[derive(Debug, clap::Args)]
struct CommonLaunchCli {
#[arg(
long,
env = env::PARTICIPANT_ID,
hide_env_values = true,
value_name = "ID"
)]
participant_id: Option<String>,
#[arg(
long,
env = env::EXECUTION_ID,
hide_env_values = true,
value_name = "ID"
)]
execution_id: Option<String>,
#[arg(
long,
env = env::PRODUCER_ID,
hide_env_values = true,
value_name = "ID"
)]
producer_id: Option<String>,
#[arg(
long,
env = env::EXECUTION_ORIGIN,
hide_env_values = true,
value_name = "ORIGIN"
)]
execution_origin: Option<String>,
#[arg(long, env = env::ROBOT_ID, hide_env_values = true, value_name = "ID")]
robot_id: Option<String>,
#[arg(
long,
env = env::NAMESPACE,
hide_env_values = true,
value_name = "NAMESPACE",
default_value = "dev"
)]
namespace: Option<String>,
#[arg(
long,
env = env::ROBOT_ROOT,
hide_env_values = true,
value_name = "DIR"
)]
robot_root: Option<PathBuf>,
#[arg(
long,
env = env::COMPONENT_INSTANCE,
hide_env_values = true,
value_name = "ID"
)]
component_instance: Option<String>,
#[arg(
long,
env = env::CONNECT,
hide_env_values = true,
value_name = "ENDPOINTS"
)]
connect: Option<String>,
#[arg(
long,
env = env::CONFIG,
hide_env_values = true,
value_name = "JSON"
)]
config: Option<String>,
}
#[derive(Debug, clap::Parser)]
#[command(
name = "phoxal-participant",
about = "Run a Phoxal participant.",
long_about = None
)]
struct ClockedLaunchCli {
#[command(flatten)]
common: CommonLaunchCli,
#[arg(
long,
env = env::CLOCK,
hide_env_values = true,
value_enum,
default_value_t = ClockMode::Real
)]
clock: ClockMode,
}
#[derive(Debug, clap::Parser)]
#[command(
name = "phoxal-tool",
about = "Run a Phoxal tool.",
long_about = None
)]
struct ToolLaunchCli {
#[command(flatten)]
common: CommonLaunchCli,
}
#[derive(Debug, clap::Parser)]
#[command(
name = "phoxal-simulator",
about = "Run a Phoxal simulator.",
long_about = None
)]
struct SimulatorLaunchCli {
#[command(flatten)]
common: CommonLaunchCli,
}
impl CommonLaunchCli {
fn into_launch(
self,
default_participant_id: &'static str,
default_robot_id: &'static str,
) -> crate::Result<ParticipantLaunch> {
let participant_id =
nonempty_or(self.participant_id, || default_participant_id.to_string());
let robot_id = nonempty_or(self.robot_id, || default_robot_id.to_string());
let mut launch = ParticipantLaunch::local(participant_id, robot_id);
if let Some(execution) = self.execution_id.filter(|value| !value.is_empty()) {
launch.execution = ExecutionId::parse(&execution)
.map_err(anyhow::Error::msg)
.context("PHOXAL_EXECUTION_ID is invalid")?;
}
if let Some(producer) = self.producer_id.filter(|value| !value.is_empty()) {
launch.producer = ProducerId::parse(&producer)
.map_err(anyhow::Error::msg)
.context("PHOXAL_PRODUCER_ID is invalid")?;
}
if let Some(origin) = self.execution_origin.filter(|value| !value.is_empty()) {
launch.execution_origin = Some(ExecutionOrigin::decode(&origin).ok_or_else(|| {
anyhow::anyhow!("PHOXAL_EXECUTION_ORIGIN is malformed: '{origin}'")
})?);
}
launch.namespace = nonempty_or(self.namespace, || "dev".to_string());
launch.robot_root = self.robot_root.filter(|path| !path.as_os_str().is_empty());
launch.component_instance = self
.component_instance
.filter(|instance| !instance.is_empty());
if let Some(endpoints) = self.connect.filter(|endpoints| !endpoints.is_empty()) {
launch.bus.connect_endpoints = endpoints
.split(',')
.map(|endpoint| endpoint.trim().to_string())
.filter(|endpoint| !endpoint.is_empty())
.collect();
}
if let Some(config) = self.config.filter(|config| !config.is_empty()) {
launch.config = Some(
serde_json::from_str(&config)
.context("PHOXAL_CONFIG must be valid JSON for the participant config")?,
);
}
Ok(launch)
}
}
fn command_for<C: CommandFactory>(
default_participant_id: &'static str,
default_robot_id: &'static str,
) -> clap::Command {
C::command()
.mut_arg("participant_id", |arg| {
arg.default_value(default_participant_id)
})
.mut_arg("robot_id", |arg| arg.default_value(default_robot_id))
}
#[doc(hidden)]
pub trait ParticipantLaunchPolicy: Send + Sync + 'static {
fn from_cli(
default_participant_id: &'static str,
default_robot_id: &'static str,
) -> crate::Result<ParticipantLaunch>;
fn clock_mode(launch: &ParticipantLaunch) -> ClockMode;
}
#[doc(hidden)]
pub struct ClockedParticipantLaunch;
impl ParticipantLaunchPolicy for ClockedParticipantLaunch {
fn from_cli(
default_participant_id: &'static str,
default_robot_id: &'static str,
) -> crate::Result<ParticipantLaunch> {
let matches =
command_for::<ClockedLaunchCli>(default_participant_id, default_robot_id).get_matches();
let cli = ClockedLaunchCli::from_arg_matches(&matches)?;
let mut launch = cli
.common
.into_launch(default_participant_id, default_robot_id)?;
launch.clock = cli.clock;
Ok(launch)
}
fn clock_mode(launch: &ParticipantLaunch) -> ClockMode {
launch.clock
}
}
#[doc(hidden)]
pub struct ToolParticipantLaunch;
impl ParticipantLaunchPolicy for ToolParticipantLaunch {
fn from_cli(
default_participant_id: &'static str,
default_robot_id: &'static str,
) -> crate::Result<ParticipantLaunch> {
let matches =
command_for::<ToolLaunchCli>(default_participant_id, default_robot_id).get_matches();
let cli = ToolLaunchCli::from_arg_matches(&matches)?;
cli.common
.into_launch(default_participant_id, default_robot_id)
}
fn clock_mode(_launch: &ParticipantLaunch) -> ClockMode {
ClockMode::Clockless
}
}
#[doc(hidden)]
pub struct SimulatorParticipantLaunch;
impl ParticipantLaunchPolicy for SimulatorParticipantLaunch {
fn from_cli(
default_participant_id: &'static str,
default_robot_id: &'static str,
) -> crate::Result<ParticipantLaunch> {
let matches = command_for::<SimulatorLaunchCli>(default_participant_id, default_robot_id)
.get_matches();
let cli = SimulatorLaunchCli::from_arg_matches(&matches)?;
cli.common
.into_launch(default_participant_id, default_robot_id)
}
fn clock_mode(_launch: &ParticipantLaunch) -> ClockMode {
ClockMode::Clockless
}
}
fn nonempty_or(value: Option<String>, default: impl FnOnce() -> String) -> String {
value
.filter(|value| !value.is_empty())
.unwrap_or_else(default)
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct BusProfile {
#[serde(default)]
pub connect_endpoints: Vec<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "snake_case")]
pub enum ClockMode {
#[default]
Real,
Simulation,
Clockless,
}
impl std::fmt::Display for ClockMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClockMode::Real => f.write_str("real"),
ClockMode::Simulation => f.write_str("simulation"),
ClockMode::Clockless => f.write_str("clockless"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::error::ErrorKind;
use serial_test::serial;
fn clear_env() {
for key in env::ALL {
unsafe { std::env::remove_var(key) };
}
}
fn parse_clocked_from(args: &[&str]) -> crate::Result<ParticipantLaunch> {
let matches = command_for::<ClockedLaunchCli>("default-id", "robot")
.try_get_matches_from(args)
.map_err(anyhow::Error::from)?;
let cli = ClockedLaunchCli::from_arg_matches(&matches).map_err(anyhow::Error::from)?;
let mut launch = cli.common.into_launch("default-id", "robot")?;
launch.clock = cli.clock;
Ok(launch)
}
fn parse_tool_from(args: &[&str]) -> crate::Result<ParticipantLaunch> {
let matches = command_for::<ToolLaunchCli>("default-id", "robot")
.try_get_matches_from(args)
.map_err(anyhow::Error::from)?;
let cli = ToolLaunchCli::from_arg_matches(&matches).map_err(anyhow::Error::from)?;
cli.common.into_launch("default-id", "robot")
}
fn parse_simulator_from(args: &[&str]) -> crate::Result<ParticipantLaunch> {
let matches = command_for::<SimulatorLaunchCli>("default-id", "robot")
.try_get_matches_from(args)
.map_err(anyhow::Error::from)?;
let cli = SimulatorLaunchCli::from_arg_matches(&matches).map_err(anyhow::Error::from)?;
cli.common.into_launch("default-id", "robot")
}
#[test]
#[serial]
fn cli_with_nothing_set_matches_local_defaults() {
clear_env();
let launch = parse_clocked_from(&["participant-bin"]).unwrap();
assert_eq!(launch.participant_id, "default-id");
assert_eq!(launch.robot_id, "robot");
assert_eq!(launch.namespace, "dev");
assert_eq!(launch.robot_root, None);
assert_eq!(launch.config, None);
assert!(launch.bus.connect_endpoints.is_empty());
assert_eq!(launch.clock, ClockMode::Real);
}
#[test]
#[serial]
fn env_overrides_each_launch_field() {
clear_env();
let execution = ExecutionId::mint();
let producer = ProducerId::mint();
let origin = ExecutionOrigin::mint();
unsafe {
std::env::set_var(env::PARTICIPANT_ID, "tof-3");
std::env::set_var(env::EXECUTION_ID, execution.to_string());
std::env::set_var(env::PRODUCER_ID, producer.to_string());
std::env::set_var(env::EXECUTION_ORIGIN, origin.encode());
std::env::set_var(env::ROBOT_ID, "robot-a");
std::env::set_var(env::NAMESPACE, "lab");
std::env::set_var(env::ROBOT_ROOT, "/robot");
std::env::set_var(env::COMPONENT_INSTANCE, "tof_front");
std::env::set_var(env::CONNECT, "tcp/127.0.0.1:7447, tcp/127.0.0.1:7448");
std::env::set_var(env::CONFIG, r#"{"rate_hz":10}"#);
std::env::set_var(env::CLOCK, "simulation");
}
let launch = parse_clocked_from(&["participant-bin"]).unwrap();
assert_eq!(launch.participant_id, "tof-3");
assert_eq!(launch.execution, execution);
assert_eq!(launch.producer, producer);
assert_eq!(launch.execution_origin, Some(origin));
assert_eq!(launch.robot_id, "robot-a");
assert_eq!(launch.namespace, "lab");
assert_eq!(
launch.robot_root.as_deref(),
Some(std::path::Path::new("/robot"))
);
assert_eq!(launch.component_instance.as_deref(), Some("tof_front"));
assert_eq!(
launch.bus.connect_endpoints,
vec![
"tcp/127.0.0.1:7447".to_string(),
"tcp/127.0.0.1:7448".to_string()
]
);
assert_eq!(launch.config, Some(serde_json::json!({"rate_hz": 10})));
assert_eq!(launch.clock, ClockMode::Simulation);
clear_env();
}
#[test]
#[serial]
fn flags_take_precedence_over_env() {
clear_env();
let flag_execution = ExecutionId::mint();
let flag_producer = ProducerId::mint();
unsafe {
std::env::set_var(env::PARTICIPANT_ID, "env-participant");
std::env::set_var(env::EXECUTION_ID, ExecutionId::mint().to_string());
std::env::set_var(env::PRODUCER_ID, ProducerId::mint().to_string());
std::env::set_var(env::ROBOT_ID, "env-robot");
std::env::set_var(env::NAMESPACE, "env-ns");
std::env::set_var(env::ROBOT_ROOT, "/env-robot");
std::env::set_var(env::COMPONENT_INSTANCE, "env-component");
std::env::set_var(env::CONNECT, "tcp/env:7447");
std::env::set_var(env::CONFIG, r#"{"source":"env"}"#);
std::env::set_var(env::CLOCK, "simulation");
}
let launch = parse_clocked_from(&[
"participant-bin",
"--participant-id",
"flag-participant",
"--execution-id",
&flag_execution.to_string(),
"--producer-id",
&flag_producer.to_string(),
"--robot-id",
"flag-robot",
"--namespace",
"flag-ns",
"--robot-root",
"/flag-robot",
"--component-instance",
"flag-component",
"--connect",
"tcp/flag:7447",
"--config",
r#"{"source":"flag"}"#,
"--clock",
"real",
])
.unwrap();
assert_eq!(launch.participant_id, "flag-participant");
assert_eq!(launch.execution, flag_execution);
assert_eq!(launch.producer, flag_producer);
assert_eq!(launch.robot_id, "flag-robot");
assert_eq!(launch.namespace, "flag-ns");
assert_eq!(
launch.robot_root.as_deref(),
Some(std::path::Path::new("/flag-robot"))
);
assert_eq!(launch.component_instance.as_deref(), Some("flag-component"));
assert_eq!(launch.bus.connect_endpoints, vec!["tcp/flag:7447"]);
assert_eq!(launch.config, Some(serde_json::json!({"source": "flag"})));
assert_eq!(launch.clock, ClockMode::Real);
clear_env();
}
#[test]
#[serial]
fn rejects_invalid_config_json_and_clock() {
clear_env();
unsafe { std::env::set_var(env::CONFIG, "not json") };
assert!(parse_clocked_from(&["participant-bin"]).is_err());
unsafe {
std::env::remove_var(env::CONFIG);
std::env::set_var(env::CLOCK, "wallclock");
}
let err = command_for::<ClockedLaunchCli>("default-id", "robot")
.try_get_matches_from(["participant-bin"])
.unwrap_err();
assert_eq!(err.kind(), ErrorKind::InvalidValue);
clear_env();
}
#[test]
#[serial]
fn help_lists_contract_env_names_without_values() {
clear_env();
unsafe { std::env::set_var(env::CONFIG, r#"{"secret":"do-not-print"}"#) };
let mut help = Vec::new();
command_for::<ClockedLaunchCli>("default-id", "robot")
.write_long_help(&mut help)
.unwrap();
let help = String::from_utf8(help).unwrap();
for (flag, env_name) in [
("--participant-id", env::PARTICIPANT_ID),
("--execution-id", env::EXECUTION_ID),
("--producer-id", env::PRODUCER_ID),
("--execution-origin", env::EXECUTION_ORIGIN),
("--robot-id", env::ROBOT_ID),
("--namespace", env::NAMESPACE),
("--robot-root", env::ROBOT_ROOT),
("--component-instance", env::COMPONENT_INSTANCE),
("--connect", env::CONNECT),
("--config", env::CONFIG),
("--clock", env::CLOCK),
] {
assert!(help.contains(flag), "help should list {flag}");
assert!(help.contains(env_name), "help should list {env_name}");
}
assert!(!help.contains("do-not-print"));
clear_env();
}
#[test]
#[serial]
fn a_malformed_identity_or_origin_is_rejected_rather_than_silently_replaced() {
clear_env();
let error = parse_tool_from(&["tool-bin", "--execution-id", "not-an-id"]).unwrap_err();
assert!(
error.to_string().contains("PHOXAL_EXECUTION_ID is invalid"),
"{error:#}"
);
let error = parse_tool_from(&["tool-bin", "--producer-id", "0011"]).unwrap_err();
assert!(
error.to_string().contains("PHOXAL_PRODUCER_ID is invalid"),
"{error:#}"
);
let error =
parse_clocked_from(&["participant-bin", "--execution-origin", "1:2"]).unwrap_err();
assert!(
error
.to_string()
.contains("PHOXAL_EXECUTION_ORIGIN is malformed"),
"{error:#}"
);
let first = parse_tool_from(&["tool-bin"]).unwrap();
let second = parse_tool_from(&["tool-bin"]).unwrap();
assert_ne!(first.execution, second.execution);
assert_ne!(first.producer, second.producer);
assert_eq!(first.execution_origin, None);
clear_env();
}
#[test]
#[serial]
fn a_launch_record_round_trips_its_identities_and_origin_through_json() {
let launch = ParticipantLaunch::local("drive", "robot")
.with_execution_origin(ExecutionOrigin::mint());
let encoded = serde_json::to_string(&launch).unwrap();
let decoded: ParticipantLaunch = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded, launch);
assert!(encoded.contains(&launch.execution.to_string()));
let malformed =
encoded.replace(&launch.execution_origin.unwrap().encode(), "not-an-origin");
assert!(serde_json::from_str::<ParticipantLaunch>(&malformed).is_err());
}
#[test]
#[serial]
fn tool_cli_has_no_clock_input() {
clear_env();
unsafe { std::env::set_var(env::CLOCK, "simulation") };
let launch = parse_tool_from(&["tool-bin"]).unwrap();
assert_eq!(launch.clock, ClockMode::Real);
let mut help = Vec::new();
command_for::<ToolLaunchCli>("default-id", "robot")
.write_long_help(&mut help)
.unwrap();
let help = String::from_utf8(help).unwrap();
assert!(!help.contains("--clock"));
assert!(!help.contains(env::CLOCK));
for arguments in [
vec!["tool-bin", "--clock", "simulation"],
vec!["tool-bin", "--simulation"],
] {
let error = command_for::<ToolLaunchCli>("default-id", "robot")
.try_get_matches_from(arguments)
.unwrap_err();
assert_eq!(error.kind(), ErrorKind::UnknownArgument);
}
let mut programmatic = ParticipantLaunch::local("tool", "robot");
programmatic.clock = ClockMode::Simulation;
assert_eq!(
ToolParticipantLaunch::clock_mode(&programmatic),
ClockMode::Clockless,
"a tool ignores a requested clock mode: it joins the execution, not the clock"
);
assert_eq!(
ClockedParticipantLaunch::clock_mode(&programmatic),
ClockMode::Simulation
);
clear_env();
}
#[test]
#[serial]
fn simulator_cli_has_no_clock_input() {
clear_env();
unsafe { std::env::set_var(env::CLOCK, "simulation") };
let launch = parse_simulator_from(&["simulator-bin"]).unwrap();
assert_eq!(launch.clock, ClockMode::Real);
let mut help = Vec::new();
command_for::<SimulatorLaunchCli>("default-id", "robot")
.write_long_help(&mut help)
.unwrap();
let help = String::from_utf8(help).unwrap();
assert!(!help.contains("--clock"));
assert!(!help.contains(env::CLOCK));
for arguments in [
vec!["simulator-bin", "--clock", "simulation"],
vec!["simulator-bin", "--simulation"],
] {
let error = command_for::<SimulatorLaunchCli>("default-id", "robot")
.try_get_matches_from(arguments)
.unwrap_err();
assert_eq!(error.kind(), ErrorKind::UnknownArgument);
}
let mut programmatic = ParticipantLaunch::local("simulator", "robot");
programmatic.clock = ClockMode::Simulation;
assert_eq!(
SimulatorParticipantLaunch::clock_mode(&programmatic),
ClockMode::Clockless
);
assert_eq!(
ClockedParticipantLaunch::clock_mode(&programmatic),
ClockMode::Simulation
);
clear_env();
}
}