use std::path::PathBuf;
use anyhow::Context;
use clap::{CommandFactory, FromArgMatches};
use serde::{Deserialize, Deserializer, Serialize};
pub const DEFAULT_SHUTDOWN_GRACE_MS: u64 = 2000;
pub const MAX_EXECUTION_DEVICE_ID_BYTES: usize = 64;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct ExecutionDeviceId(String);
impl ExecutionDeviceId {
pub fn new(value: impl Into<String>) -> Result<Self, String> {
let value = value.into();
if value.is_empty() {
return Err("execution-device id must not be empty".to_string());
}
if value.len() > MAX_EXECUTION_DEVICE_ID_BYTES {
return Err(format!(
"execution-device id must be at most {MAX_EXECUTION_DEVICE_ID_BYTES} UTF-8 bytes"
));
}
if value.chars().any(char::is_control) {
return Err("execution-device id must not contain control characters".to_string());
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ExecutionDeviceId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
impl AsRef<str> for ExecutionDeviceId {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl<'de> Deserialize<'de> for ExecutionDeviceId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(serde::de::Error::custom)
}
}
pub mod env {
pub const PARTICIPANT_ID: &str = "PHOXAL_PARTICIPANT_ID";
pub const INCARNATION: &str = "PHOXAL_INCARNATION";
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 EXECUTION_DEVICE_ID: &str = "PHOXAL_EXECUTION_DEVICE_ID";
pub const CONNECT: &str = "PHOXAL_CONNECT";
pub const CONFIG: &str = "PHOXAL_CONFIG";
pub const CLOCK: &str = "PHOXAL_CLOCK";
pub const ALL: &[&str] = &[
PARTICIPANT_ID,
INCARNATION,
ROBOT_ID,
NAMESPACE,
ROBOT_ROOT,
COMPONENT_INSTANCE,
EXECUTION_DEVICE_ID,
CONNECT,
CONFIG,
CLOCK,
];
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ParticipantLaunch {
pub participant_id: String,
#[serde(default)]
pub incarnation: u64,
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)]
pub execution_device_id: Option<ExecutionDeviceId>,
#[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(),
incarnation: 0,
namespace: "dev".to_string(),
robot_id: robot_id.into(),
bus: BusProfile::default(),
clock: ClockMode::Real,
config: None,
robot_root: None,
component_instance: None,
execution_device_id: None,
shutdown_grace_ms: DEFAULT_SHUTDOWN_GRACE_MS,
}
}
pub fn with_robot_root(mut self, root: impl Into<PathBuf>) -> Self {
self.robot_root = Some(root.into());
self
}
pub fn with_component_instance(mut self, instance: impl Into<String>) -> Self {
self.component_instance = Some(instance.into());
self
}
pub fn with_execution_device_id(mut self, identity: ExecutionDeviceId) -> Self {
self.execution_device_id = Some(identity);
self
}
}
#[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::INCARNATION,
hide_env_values = true,
value_name = "U64",
default_value_t = 0
)]
incarnation: u64,
#[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::EXECUTION_DEVICE_ID,
hide_env_values = true,
value_name = "ID"
)]
execution_device_id: 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);
launch.incarnation = self.incarnation;
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());
launch.execution_device_id = self
.execution_device_id
.map(ExecutionDeviceId::new)
.transpose()
.map_err(anyhow::Error::msg)
.context("PHOXAL_EXECUTION_DEVICE_ID is invalid")?;
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::Real
}
}
#[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::Real
}
}
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,
}
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"),
}
}
}
#[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.incarnation, 0);
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();
unsafe {
std::env::set_var(env::PARTICIPANT_ID, "tof-3");
std::env::set_var(env::INCARNATION, "41");
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::EXECUTION_DEVICE_ID, "project-e2e");
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.incarnation, 41);
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
.execution_device_id
.as_ref()
.map(ExecutionDeviceId::as_str),
Some("project-e2e")
);
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();
unsafe {
std::env::set_var(env::PARTICIPANT_ID, "env-participant");
std::env::set_var(env::INCARNATION, "41");
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::EXECUTION_DEVICE_ID, "env-project");
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",
"--incarnation",
"42",
"--robot-id",
"flag-robot",
"--namespace",
"flag-ns",
"--robot-root",
"/flag-robot",
"--component-instance",
"flag-component",
"--execution-device-id",
"flag-project",
"--connect",
"tcp/flag:7447",
"--config",
r#"{"source":"flag"}"#,
"--clock",
"real",
])
.unwrap();
assert_eq!(launch.participant_id, "flag-participant");
assert_eq!(launch.incarnation, 42);
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
.execution_device_id
.as_ref()
.map(ExecutionDeviceId::as_str),
Some("flag-project")
);
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),
("--incarnation", env::INCARNATION),
("--robot-id", env::ROBOT_ID),
("--namespace", env::NAMESPACE),
("--robot-root", env::ROBOT_ROOT),
("--component-instance", env::COMPONENT_INSTANCE),
("--execution-device-id", env::EXECUTION_DEVICE_ID),
("--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 execution_device_id_is_required_nonempty_bounded_and_control_free_when_present() {
clear_env();
for invalid in [
String::new(),
"line\nbreak".to_string(),
"x".repeat(MAX_EXECUTION_DEVICE_ID_BYTES + 1),
] {
let error = parse_tool_from(&["tool-bin", "--execution-device-id", invalid.as_str()])
.unwrap_err();
assert!(
error
.to_string()
.contains("PHOXAL_EXECUTION_DEVICE_ID is invalid"),
"{error:#}"
);
}
let boundary = "é".repeat(MAX_EXECUTION_DEVICE_ID_BYTES / 2);
let launch = parse_tool_from(&["tool-bin", "--execution-device-id", &boundary]).unwrap();
assert_eq!(
launch
.execution_device_id
.as_ref()
.map(ExecutionDeviceId::as_str),
Some(boundary.as_str())
);
clear_env();
}
#[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::Real
);
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::Real
);
assert_eq!(
ClockedParticipantLaunch::clock_mode(&programmatic),
ClockMode::Simulation
);
clear_env();
}
}