#![warn(missing_docs)]
use crate::{
config::{ByteSize, Input, NodeRunConfig},
id::{DataId, NodeId, OperatorId},
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_with_expand_env::with_expand_envs;
use std::{
collections::{BTreeMap, BTreeSet},
fmt,
path::PathBuf,
};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum OutputFraming {
#[default]
Raw,
ArrowIpc,
}
pub const SHELL_SOURCE: &str = "shell";
pub const DYNAMIC_SOURCE: &str = "dynamic";
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(title = "dora-rs specification")]
#[non_exhaustive]
pub struct Descriptor {
pub nodes: Vec<Node>,
#[schemars(skip)]
pub deploy: Option<Deploy>,
#[schemars(skip)]
#[serde(default)]
pub debug: Debug,
#[serde(default)]
pub health_check_interval: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strict_types: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_when_nodes_finish: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub type_rules: Vec<TypeRuleDef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<BTreeMap<String, EnvValue>>,
}
impl Descriptor {
pub fn new(nodes: Vec<Node>) -> Self {
Self {
nodes,
deploy: None,
debug: Default::default(),
health_check_interval: None,
strict_types: None,
exit_when_nodes_finish: None,
type_rules: Default::default(),
env: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct TypeRuleDef {
pub from: String,
pub to: String,
}
impl TypeRuleDef {
pub fn new(from: String, to: String) -> Self {
Self { from, to }
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum RestartPolicy {
#[default]
Never,
OnFailure,
Always,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct Deploy {
pub machine: Option<String>,
pub working_dir: Option<PathBuf>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
#[serde(default)]
pub distribute: DistributeStrategy,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DistributeStrategy {
#[default]
Local,
Scp,
Http,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct Debug {
#[serde(default)]
pub enable_debug_inspection: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct Node {
pub id: NodeId,
pub name: Option<String>,
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args: Option<String>,
pub env: Option<BTreeMap<String, EnvValue>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operators: Option<RuntimeNode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operator: Option<SingleOperatorDefinition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ros2: Option<Ros2BridgeConfig>,
#[serde(default)]
pub outputs: BTreeSet<DataId>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub output_types: BTreeMap<DataId, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub output_framing: BTreeMap<DataId, OutputFraming>,
#[serde(default)]
pub inputs: BTreeMap<DataId, Input>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub input_types: BTreeMap<DataId, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub output_metadata: BTreeMap<DataId, Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub send_stdout_as: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub send_logs_as: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_log_level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_log_size: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(max = 100))]
pub max_rotated_files: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hub: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
#[serde(default)]
pub restart_policy: RestartPolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shared_memory_pool_size: Option<ByteSize>,
#[serde(default)]
pub max_restarts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart_delay: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_restart_delay: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart_window: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_check_timeout: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finish_grace_secs: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub module: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub params: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_affinity: Option<Vec<usize>>,
#[schemars(skip)]
pub deploy: Option<Deploy>,
}
impl Node {
pub fn new(id: NodeId) -> Self {
Self {
id,
name: None,
description: None,
path: None,
path_sha256: None,
args: None,
env: None,
operators: None,
operator: None,
ros2: None,
outputs: Default::default(),
output_types: Default::default(),
output_framing: Default::default(),
inputs: Default::default(),
input_types: Default::default(),
shared_memory_pool_size: None,
output_metadata: Default::default(),
pattern: None,
send_stdout_as: None,
send_logs_as: None,
min_log_level: None,
max_log_size: None,
max_rotated_files: None,
build: None,
git: None,
hub: None,
branch: None,
tag: None,
rev: None,
restart_policy: Default::default(),
max_restarts: 0,
restart_delay: None,
max_restart_delay: None,
restart_window: None,
health_check_timeout: None,
finish_grace_secs: None,
module: None,
params: Default::default(),
cpu_affinity: None,
deploy: None,
}
}
}
#[allow(missing_docs)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ResolvedNode {
pub id: NodeId,
pub name: Option<String>,
pub description: Option<String>,
pub env: Option<BTreeMap<String, EnvValue>>,
#[serde(default)]
pub cpu_affinity: Option<Vec<usize>>,
#[serde(default)]
pub deploy: Option<Deploy>,
#[serde(flatten)]
pub kind: CoreNodeKind,
}
#[allow(missing_docs)]
impl ResolvedNode {
pub fn new(id: NodeId, kind: CoreNodeKind) -> Self {
Self {
id,
name: None,
description: None,
env: None,
cpu_affinity: None,
deploy: None,
kind,
}
}
pub fn from_node(node: Node, kind: CoreNodeKind) -> Self {
Self {
id: node.id,
name: node.name,
description: node.description,
env: node.env,
cpu_affinity: node.cpu_affinity,
deploy: node.deploy,
kind,
}
}
pub fn has_git_source(&self) -> bool {
self.kind
.as_custom()
.map(|n| n.source.is_git())
.unwrap_or_default()
}
}
#[allow(missing_docs)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[allow(clippy::large_enum_variant)]
pub enum CoreNodeKind {
#[serde(rename = "operators")]
Runtime(RuntimeNode),
Custom(CustomNode),
}
#[allow(missing_docs)]
impl CoreNodeKind {
pub fn as_custom(&self) -> Option<&CustomNode> {
match self {
CoreNodeKind::Runtime(_) => None,
CoreNodeKind::Custom(custom_node) => Some(custom_node),
}
}
}
#[allow(missing_docs)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(transparent)]
pub struct RuntimeNode {
pub operators: Vec<OperatorDefinition>,
}
#[allow(missing_docs)]
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
pub struct OperatorDefinition {
pub id: OperatorId,
#[serde(flatten)]
pub config: OperatorConfig,
}
#[allow(missing_docs)]
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
#[non_exhaustive]
pub struct SingleOperatorDefinition {
pub id: Option<OperatorId>,
#[serde(flatten)]
pub config: OperatorConfig,
}
#[allow(missing_docs)]
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
#[non_exhaustive]
pub struct OperatorConfig {
pub name: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub inputs: BTreeMap<DataId, Input>,
#[serde(default)]
pub outputs: BTreeSet<DataId>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub output_types: BTreeMap<DataId, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub output_framing: BTreeMap<DataId, OutputFraming>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub input_types: BTreeMap<DataId, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub output_metadata: BTreeMap<DataId, Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(flatten)]
pub source: OperatorSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub send_stdout_as: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub send_logs_as: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_log_level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_log_size: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(max = 100))]
pub max_rotated_files: Option<u32>,
}
#[allow(missing_docs)]
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
#[serde(rename_all = "kebab-case")]
pub enum OperatorSource {
SharedLibrary(String),
Python(PythonSource),
#[schemars(skip)]
Wasm(String),
}
#[allow(missing_docs)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(from = "PythonSourceDef", into = "PythonSourceDef")]
pub struct PythonSource {
pub source: String,
pub conda_env: Option<String>,
}
#[allow(missing_docs)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum PythonSourceDef {
SourceOnly(String),
WithOptions {
source: String,
conda_env: Option<String>,
},
}
impl From<PythonSource> for PythonSourceDef {
fn from(input: PythonSource) -> Self {
match input {
PythonSource {
source,
conda_env: None,
} => Self::SourceOnly(source),
PythonSource { source, conda_env } => Self::WithOptions { source, conda_env },
}
}
}
impl From<PythonSourceDef> for PythonSource {
fn from(value: PythonSourceDef) -> Self {
match value {
PythonSourceDef::SourceOnly(source) => Self {
source,
conda_env: None,
},
PythonSourceDef::WithOptions { source, conda_env } => Self { source, conda_env },
}
}
}
pub const RUNTIME_SHARED_LIBRARY: &str = "shared-library";
pub const RUNTIME_PYTHON: &str = "python";
pub const RUNTIME_WASM: &str = "wasm";
impl OperatorSource {
pub fn runtime_name(&self) -> &'static str {
match self {
OperatorSource::SharedLibrary(_) => RUNTIME_SHARED_LIBRARY,
OperatorSource::Python(_) => RUNTIME_PYTHON,
OperatorSource::Wasm(_) => RUNTIME_WASM,
}
}
}
#[allow(missing_docs)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[non_exhaustive]
pub struct CustomNode {
pub path: String,
pub source: NodeSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args: Option<String>,
pub envs: Option<BTreeMap<String, EnvValue>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub send_stdout_as: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub send_logs_as: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_log_level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_log_size: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(max = 100))]
pub max_rotated_files: Option<u32>,
#[serde(default)]
pub restart_policy: RestartPolicy,
#[serde(default)]
pub max_restarts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart_delay: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_restart_delay: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart_window: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_check_timeout: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finish_grace_secs: Option<f64>,
#[serde(flatten)]
pub run_config: NodeRunConfig,
}
impl CustomNode {
pub fn new(path: String) -> Self {
Self {
path,
source: NodeSource::Local,
path_sha256: None,
args: None,
envs: None,
build: None,
send_stdout_as: None,
send_logs_as: None,
min_log_level: None,
max_log_size: None,
max_rotated_files: None,
restart_policy: Default::default(),
max_restarts: 0,
restart_delay: None,
max_restart_delay: None,
restart_window: None,
health_check_timeout: None,
finish_grace_secs: None,
run_config: NodeRunConfig::default(),
}
}
pub fn from_node(node: &mut Node, path: String) -> Self {
Self {
path,
source: NodeSource::Local,
path_sha256: node.path_sha256.take(),
args: node.args.take(),
envs: None,
build: node.build.take(),
send_stdout_as: node.send_stdout_as.take(),
send_logs_as: node.send_logs_as.take(),
min_log_level: node.min_log_level.take(),
max_log_size: node.max_log_size.take(),
max_rotated_files: node.max_rotated_files.take(),
restart_policy: node.restart_policy,
max_restarts: node.max_restarts,
restart_delay: node.restart_delay.take(),
max_restart_delay: node.max_restart_delay.take(),
restart_window: node.restart_window.take(),
health_check_timeout: node.health_check_timeout.take(),
finish_grace_secs: node.finish_grace_secs.take(),
run_config: NodeRunConfig {
inputs: std::mem::take(&mut node.inputs),
outputs: std::mem::take(&mut node.outputs),
output_types: std::mem::take(&mut node.output_types),
output_framing: std::mem::take(&mut node.output_framing),
input_types: std::mem::take(&mut node.input_types),
shared_memory_pool_size: node.shared_memory_pool_size.take(),
},
}
}
}
#[allow(missing_docs)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub enum NodeSource {
Local,
GitBranch {
repo: String,
rev: Option<GitRepoRev>,
},
}
#[allow(missing_docs)]
impl NodeSource {
pub fn is_git(&self) -> bool {
matches!(self, Self::GitBranch { .. })
}
}
#[allow(missing_docs)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub enum GitRepoRev {
Branch(String),
Tag(String),
Rev(String),
}
#[allow(missing_docs)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum EnvValue {
#[serde(deserialize_with = "with_expand_envs")]
Bool(bool),
#[serde(deserialize_with = "with_expand_envs")]
Integer(i64),
#[serde(deserialize_with = "with_expand_envs")]
Float(f64),
#[serde(deserialize_with = "with_expand_envs")]
String(String),
}
impl fmt::Display for EnvValue {
fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
match self {
EnvValue::Bool(bool) => fmt.write_str(&bool.to_string()),
EnvValue::Integer(i64) => fmt.write_str(&i64.to_string()),
EnvValue::Float(f64) => fmt.write_str(&f64.to_string()),
EnvValue::String(str) => fmt.write_str(str),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Ros2BridgeConfig {
#[serde(default)]
pub transport: Ros2TransportConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topic: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_type: Option<String>,
#[serde(default)]
pub direction: Ros2Direction,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topics: Option<Vec<Ros2TopicConfig>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<Ros2Role>,
#[serde(default)]
pub qos: Ros2QosConfig,
#[serde(default = "default_ros2_namespace")]
pub namespace: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_name: Option<String>,
}
impl Default for Ros2BridgeConfig {
fn default() -> Self {
Self {
transport: Ros2TransportConfig::default(),
topic: None,
message_type: None,
direction: Ros2Direction::default(),
topics: None,
service: None,
service_type: None,
action: None,
action_type: None,
role: None,
qos: Ros2QosConfig::default(),
namespace: default_ros2_namespace(),
node_name: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Ros2TransportConfig {
#[default]
Dds,
Zenoh {
compatibility: RmwZenohCompatibility,
#[serde(default, skip_serializing_if = "Option::is_none")]
config_uri: Option<PathBuf>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RmwZenohCompatibility {
Humble,
Rep2016,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Ros2Role {
Client,
Server,
}
fn default_ros2_namespace() -> String {
"/".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Ros2TopicConfig {
pub topic: String,
pub message_type: String,
#[serde(default)]
pub direction: Ros2Direction,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qos: Option<Ros2QosConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Ros2Direction {
#[default]
Subscribe,
Publish,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Ros2QosConfig {
#[serde(default)]
pub reliable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub durability: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub liveliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lease_duration: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_blocking_time: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keep_last: Option<i32>,
#[serde(default)]
pub keep_all: bool,
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_matches_yaml_defaults<T: Serialize + serde::de::DeserializeOwned>(
yaml: &str,
constructed: T,
what: &str,
) {
let from_yaml: T = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
serde_yaml::to_value(&from_yaml).unwrap(),
serde_yaml::to_value(&constructed).unwrap(),
"`{what}` drifted from the defaults serde applies"
);
}
#[test]
fn node_new_matches_yaml_defaults() {
assert_matches_yaml_defaults(
"id: some-node\n",
Node::new("some-node".to_owned().into()),
"Node::new",
);
}
#[test]
fn constructors_match_yaml_defaults() {
assert_matches_yaml_defaults(
"nodes: []\n",
Descriptor::new(Vec::new()),
"Descriptor::new",
);
assert_matches_yaml_defaults("{}\n", Deploy::default(), "Deploy::default");
assert_matches_yaml_defaults("{}\n", Debug::default(), "Debug::default");
assert_matches_yaml_defaults("{}\n", NodeRunConfig::default(), "NodeRunConfig::default");
assert_matches_yaml_defaults(
"from: a\nto: b\n",
TypeRuleDef::new("a".to_owned(), "b".to_owned()),
"TypeRuleDef::new",
);
}
#[test]
fn ros2_transport_defaults_to_dds() {
let config: Ros2BridgeConfig =
serde_yaml::from_str("topic: /chatter\nmessage_type: std_msgs/String\n").unwrap();
assert!(matches!(config.transport, Ros2TransportConfig::Dds));
}
#[test]
fn ros2_transport_parses_humble_zenoh() {
let config: Ros2BridgeConfig = serde_yaml::from_str(
"transport:\n kind: zenoh\n compatibility: humble\n config_uri: /tmp/rmw.json5\n\
topic: /chatter\nmessage_type: std_msgs/String\n",
)
.unwrap();
assert_eq!(
config.transport,
Ros2TransportConfig::Zenoh {
compatibility: RmwZenohCompatibility::Humble,
config_uri: Some("/tmp/rmw.json5".into()),
}
);
}
#[test]
fn ros2_transport_rejects_unknown_zenoh_compatibility() {
let error = serde_yaml::from_str::<Ros2BridgeConfig>(
"transport:\n kind: zenoh\n compatibility: automatic\n\
topic: /chatter\nmessage_type: std_msgs/String\n",
)
.unwrap_err();
assert!(error.to_string().contains("unknown variant `automatic`"));
}
#[test]
fn output_framing_defaults_to_raw() {
let yaml = r#"
nodes:
- id: test
path: test.py
outputs:
- data
"#;
let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
assert!(desc.nodes[0].output_framing.is_empty());
}
#[test]
fn output_framing_parses_arrow_ipc() {
let yaml = r#"
nodes:
- id: test
path: test.py
outputs:
- data
output_framing:
data: arrow-ipc
"#;
let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
desc.nodes[0].output_framing.get::<DataId>(&"data".into()),
Some(&OutputFraming::ArrowIpc)
);
}
#[test]
fn cpu_affinity_parses() {
let yaml = r#"
nodes:
- id: test
path: test.py
cpu_affinity: [0, 2, 4]
"#;
let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
assert_eq!(desc.nodes[0].cpu_affinity, Some(vec![0, 2, 4]));
}
#[test]
fn cpu_affinity_defaults_to_none() {
let yaml = r#"
nodes:
- id: test
path: test.py
"#;
let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
assert_eq!(desc.nodes[0].cpu_affinity, None);
}
#[test]
fn debug_flag_accepts_new_name() {
let yaml = r#"
nodes:
- id: test
path: test.py
debug:
enable_debug_inspection: true
"#;
let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
assert!(desc.debug.enable_debug_inspection);
}
#[test]
fn removed_unstable_key_prefix_is_rejected_not_ignored() {
for (key, block) in [
("_unstable_deploy", "_unstable_deploy:\n machine: m1\n"),
(
"_unstable_debug",
"_unstable_debug:\n enable_debug_inspection: true\n",
),
] {
let yaml = format!("nodes:\n - id: test\n path: test.py\n{block}");
let err = serde_yaml::from_str::<Descriptor>(&yaml)
.expect_err("the pre-1.0 `_unstable_` key must be rejected");
assert!(
err.to_string().contains(key),
"error should name `{key}`, got: {err}"
);
}
}
#[test]
fn debug_flag_rejects_the_removed_legacy_alias() {
let yaml = r#"
nodes:
- id: test
path: test.py
debug:
publish_all_messages_to_zenoh: true
"#;
let err = serde_yaml::from_str::<Descriptor>(yaml)
.expect_err("removed alias must be rejected, not silently ignored");
assert!(
err.to_string().contains("publish_all_messages_to_zenoh"),
"error should name the offending field, got: {err}"
);
}
#[test]
fn operator_source_shared_library_names_its_runtime() {
let cfg: OperatorConfig = serde_yaml::from_str("shared-library: build/op").unwrap();
assert!(matches!(&cfg.source, OperatorSource::SharedLibrary(s) if s == "build/op"));
assert_eq!(cfg.source.runtime_name(), RUNTIME_SHARED_LIBRARY);
assert_eq!(cfg.source.runtime_name(), "shared-library");
}
#[test]
fn operator_source_python_source_only_names_its_runtime() {
let cfg: OperatorConfig = serde_yaml::from_str("python: op.py").unwrap();
assert!(matches!(&cfg.source, OperatorSource::Python(py) if py.source == "op.py"));
assert_eq!(cfg.source.runtime_name(), RUNTIME_PYTHON);
}
#[test]
fn operator_source_python_with_conda_env_names_its_runtime() {
let cfg: OperatorConfig =
serde_yaml::from_str("python:\n source: op.py\n conda_env: my-env").unwrap();
match &cfg.source {
OperatorSource::Python(py) => {
assert_eq!(py.source, "op.py");
assert_eq!(py.conda_env.as_deref(), Some("my-env"));
}
other => panic!("expected python source, got {other:?}"),
}
assert_eq!(cfg.source.runtime_name(), RUNTIME_PYTHON);
}
#[test]
fn operator_source_wasm_names_its_runtime() {
let cfg: OperatorConfig = serde_yaml::from_str("wasm: op.wasm").unwrap();
assert!(matches!(&cfg.source, OperatorSource::Wasm(s) if s == "op.wasm"));
assert_eq!(cfg.source.runtime_name(), RUNTIME_WASM);
}
#[test]
fn operator_source_runtime_survives_a_serde_roundtrip() {
for yaml in ["shared-library: build/op", "python: op.py", "wasm: op.wasm"] {
let cfg: OperatorConfig = serde_yaml::from_str(yaml).unwrap();
let serialized = serde_yaml::to_string(&cfg).unwrap();
let reparsed: OperatorConfig = serde_yaml::from_str(&serialized).unwrap();
assert_eq!(cfg.source.runtime_name(), reparsed.source.runtime_name());
}
}
}