use crate::model::NodeModelConfig;
use crate::relay_topics::RelayTopicsField;
use crate::topic_utils::Topics;
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
pub fn default_frame_skip() -> u32 {
1
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)]
#[schemars(description = "Fields common to every node's configuration, reserved across all node types.")]
pub struct NodeConfigBase {
#[serde(flatten)]
pub relay: RelayTopicsField,
#[serde(default)]
pub topics: Topics,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<NodeModelConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_rate_hz: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub controller: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct NodeConfigBaseShadow {
#[serde(default)]
relay_topics: Option<Vec<String>>,
#[serde(default)]
topics: Topics,
#[serde(default)]
model: Option<NodeModelConfig>,
#[serde(default)]
status_rate_hz: Option<f32>,
#[serde(default)]
controller: Option<serde_json::Value>,
}
impl<'de> Deserialize<'de> for NodeConfigBase {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let shadow = NodeConfigBaseShadow::deserialize(deserializer)?;
Ok(NodeConfigBase {
relay: RelayTopicsField {
relay_topics: shadow.relay_topics,
},
topics: shadow.topics,
model: shadow.model,
status_rate_hz: shadow.status_rate_hz,
controller: shadow.controller,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flattens_relay_topics_at_the_top_level() {
let base: NodeConfigBase = serde_json::from_str(r#"{ "relay_topics": ["/vision/classification"] }"#).unwrap();
assert_eq!(
base.relay.relay_topics,
Some(vec!["/vision/classification".to_string()])
);
}
#[test]
fn omitted_relay_topics_is_none() {
let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
assert_eq!(base.relay.relay_topics, None);
}
#[test]
fn round_trips_through_serialization() {
let base = NodeConfigBase {
relay: RelayTopicsField {
relay_topics: Some(vec!["/a/b".to_string()]),
},
..NodeConfigBase::default()
};
let value = serde_json::to_value(&base).unwrap();
assert_eq!(
value,
serde_json::json!({
"relay_topics": ["/a/b"],
"topics": { "publishes": [], "subscribes": [] }
})
);
}
#[test]
fn json_schema_exposes_relay_topics_field() {
let schema = schemars::schema_for!(NodeConfigBase);
let value = serde_json::to_value(&schema).unwrap();
assert!(value["properties"].get("relay_topics").is_some());
}
#[test]
fn json_schema_exposes_topics_model_status_rate_hz_and_controller_fields() {
let schema = schemars::schema_for!(NodeConfigBase);
let value = serde_json::to_value(&schema).unwrap();
for field in ["topics", "model", "status_rate_hz", "controller"] {
assert!(value["properties"].get(field).is_some(), "missing property: {field}");
}
}
#[test]
fn deserializes_topics_at_the_top_level() {
let json = r#"{
"topics": { "publishes": [{"output": "/a/b"}], "subscribes": [] }
}"#;
let base: NodeConfigBase = serde_json::from_str(json).unwrap();
assert_eq!(base.topics.published_topic("output", "/default"), "/a/b");
}
#[test]
fn omitted_topics_defaults_to_empty() {
let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
assert!(base.topics.publishes.is_empty());
assert!(base.topics.subscribes.is_empty());
}
#[test]
fn deserializes_model_at_the_top_level() {
let json = r#"{"model": {"name": "yolov8n", "path": "models/catalog/yolov8n/model.onnx"}}"#;
let base: NodeConfigBase = serde_json::from_str(json).unwrap();
let model = base.model.unwrap();
assert_eq!(model.name, "yolov8n");
assert_eq!(model.path, "models/catalog/yolov8n/model.onnx");
}
#[test]
fn omitted_model_is_none() {
let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
assert_eq!(base.model, None);
}
#[test]
fn deserializes_provider_nested_under_model() {
let json = r#"{"model": {"name": "gpt-4o-mini", "provider": "openai"}}"#;
let base: NodeConfigBase = serde_json::from_str(json).unwrap();
let model = base.model.unwrap();
assert_eq!(model.provider, Some(crate::model::Provider::Openai));
assert_eq!(model.name, "gpt-4o-mini");
}
#[test]
fn deserializes_status_rate_hz_at_the_top_level() {
let json = r#"{"status_rate_hz": 5.0}"#;
let base: NodeConfigBase = serde_json::from_str(json).unwrap();
assert_eq!(base.status_rate_hz, Some(5.0));
}
#[test]
fn omitted_status_rate_hz_is_none() {
let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
assert_eq!(base.status_rate_hz, None);
}
#[test]
fn deserializes_controller_at_the_top_level_as_opaque_json() {
let json = r#"{"controller": {"type": "mpu6050", "i2c_bus": "/dev/i2c-1"}}"#;
let base: NodeConfigBase = serde_json::from_str(json).unwrap();
assert_eq!(
base.controller,
Some(serde_json::json!({"type": "mpu6050", "i2c_bus": "/dev/i2c-1"}))
);
}
#[test]
fn omitted_controller_is_none() {
let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
assert_eq!(base.controller, None);
}
#[test]
fn deserializes_cleanly_when_flattened_into_a_deny_unknown_fields_struct() {
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct OuterConfig {
#[allow(dead_code)]
name: String,
#[serde(flatten)]
base: NodeConfigBase,
}
let json = r#"{"name": "n", "relay_topics": ["/a/b"]}"#;
let outer: OuterConfig =
serde_json::from_str(json).expect("relay_topics must deserialize through the outer flatten");
assert_eq!(outer.base.relay.relay_topics, Some(vec!["/a/b".to_string()]));
let typo_json = r#"{"name": "n", "relay_topicz": ["/a/b"]}"#;
let err = serde_json::from_str::<OuterConfig>(typo_json).unwrap_err();
assert!(
err.to_string().to_lowercase().contains("unknown field"),
"a real typo must still be rejected: {err}"
);
}
#[test]
fn deserializes_all_new_fields_cleanly_when_flattened_into_a_deny_unknown_fields_struct() {
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct OuterConfig {
#[allow(dead_code)]
name: String,
#[serde(flatten)]
base: NodeConfigBase,
}
let json = r#"{
"name": "n",
"topics": { "publishes": [{"output": "/a/b"}], "subscribes": [] },
"model": { "name": "claude-3", "provider": "claude" },
"status_rate_hz": 5.0,
"controller": { "type": "mock" }
}"#;
let outer: OuterConfig =
serde_json::from_str(json).expect("new base fields must deserialize through the outer flatten");
assert_eq!(outer.base.topics.published_topic("output", "/default"), "/a/b");
assert_eq!(outer.base.model.as_ref().unwrap().name, "claude-3");
assert_eq!(outer.base.model.unwrap().provider, Some(crate::model::Provider::Claude));
assert_eq!(outer.base.status_rate_hz, Some(5.0));
assert_eq!(outer.base.controller, Some(serde_json::json!({"type": "mock"})));
let typo_json = r#"{"name": "n", "modell": {"name": "yolov8n"}}"#;
let err = serde_json::from_str::<OuterConfig>(typo_json).unwrap_err();
assert!(
err.to_string().to_lowercase().contains("unknown field"),
"a real typo must still be rejected: {err}"
);
}
}