use std::path::PathBuf;
use anyhow::Context;
use clap::{CommandFactory, FromArgMatches};
use serde::{Deserialize, Serialize};
pub const DEFAULT_SHUTDOWN_GRACE_MS: u64 = 2000;
pub mod env {
pub const PARTICIPANT_ID: &str = "PHOXAL_PARTICIPANT_ID";
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,
ROBOT_ID,
NAMESPACE,
ROBOT_ROOT,
COMPONENT_INSTANCE,
CONNECT,
CONFIG,
CLOCK,
];
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ParticipantLaunch {
pub participant_id: String,
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(),
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(crate) fn from_cli(
default_participant_id: &'static str,
default_robot_id: &'static str,
) -> crate::Result<ParticipantLaunch> {
let matches =
LaunchCli::command_for(default_participant_id, default_robot_id).get_matches();
let cli = LaunchCli::from_arg_matches(&matches)?;
cli.into_launch(default_participant_id, default_robot_id)
}
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
}
}
#[derive(Debug, clap::Parser)]
#[command(
name = "phoxal-participant",
about = "Run a Phoxal participant.",
long_about = None
)]
struct LaunchCli {
#[arg(
long,
env = env::PARTICIPANT_ID,
hide_env_values = true,
value_name = "ID"
)]
participant_id: 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>,
#[arg(
long,
env = env::CLOCK,
hide_env_values = true,
value_enum,
default_value_t = ClockMode::Real
)]
clock: ClockMode,
}
impl LaunchCli {
fn command_for(
default_participant_id: &'static str,
default_robot_id: &'static str,
) -> clap::Command {
Self::command()
.mut_arg("participant_id", |arg| {
arg.default_value(default_participant_id)
})
.mut_arg("robot_id", |arg| arg.default_value(default_robot_id))
}
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.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")?,
);
}
launch.clock = self.clock;
Ok(launch)
}
}
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_from(args: &[&str]) -> crate::Result<ParticipantLaunch> {
let matches = LaunchCli::command_for("default-id", "robot")
.try_get_matches_from(args)
.map_err(anyhow::Error::from)?;
let cli = LaunchCli::from_arg_matches(&matches).map_err(anyhow::Error::from)?;
cli.into_launch("default-id", "robot")
}
#[test]
#[serial]
fn cli_with_nothing_set_matches_local_defaults() {
clear_env();
let launch = parse_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();
unsafe {
std::env::set_var(env::PARTICIPANT_ID, "tof-3");
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_from(&["participant-bin"]).unwrap();
assert_eq!(launch.participant_id, "tof-3");
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();
unsafe {
std::env::set_var(env::PARTICIPANT_ID, "env-participant");
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_from(&[
"participant-bin",
"--participant-id",
"flag-participant",
"--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.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_from(&["participant-bin"]).is_err());
unsafe {
std::env::remove_var(env::CONFIG);
std::env::set_var(env::CLOCK, "wallclock");
}
let err = LaunchCli::command_for("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();
LaunchCli::command_for("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),
("--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();
}
}