use anyhow::Context;
use phoxal_bundle::{ParticipantBundle, ParticipantRuntimeInputs};
use phoxal_runtime_contract::identity::ParticipantId;
pub(crate) fn participant_config<C: serde::de::DeserializeOwned>(
config: Option<&serde_json::Value>,
) -> crate::Result<C> {
let value = config.cloned().unwrap_or(serde_json::Value::Null);
Ok(serde_json::from_value(value)?)
}
pub(crate) fn participant_inputs_for_launch(
root: &std::path::Path,
participant_id: &ParticipantId,
) -> crate::Result<ParticipantRuntimeInputs> {
let bundle = ParticipantBundle::open(root, participant_id).with_context(|| {
format!(
"failed to select participant '{participant_id}' from runtime bundle {}",
root.display()
)
})?;
Ok(bundle.into_inputs())
}
#[cfg(test)]
mod tests {
use super::*;
use phoxal_fixture::staged_bundle;
#[test]
fn an_absent_config_is_json_null_not_a_missing_value() {
#[derive(Debug, serde::Deserialize)]
struct Required {
#[expect(dead_code, reason = "the field exists to make the config required")]
port: String,
}
participant_config::<()>(None).expect("a configless participant accepts absent config");
assert!(
participant_config::<Option<Required>>(None)
.expect("an optional config accepts absent config")
.is_none()
);
let error = participant_config::<Required>(None)
.expect_err("a required config must reject absent config");
assert!(
format!("{error}").contains("invalid type: null"),
"unexpected absent-config error: {error:#}"
);
let supplied = serde_json::json!({ "port": "/dev/ttyUSB0" });
participant_config::<Required>(Some(&supplied)).expect("a supplied config deserializes");
}
#[test]
fn custom_config_deserialization_rejection_is_reported_locally() {
#[derive(Debug)]
struct RejectingConfig;
impl<'de> serde::Deserialize<'de> for RejectingConfig {
fn deserialize<D: serde::Deserializer<'de>>(
_deserializer: D,
) -> std::result::Result<Self, D::Error> {
Err(serde::de::Error::custom("custom config rejection"))
}
}
let value = serde_json::json!({ "accepted_by_schema": true });
let error = participant_config::<RejectingConfig>(Some(&value))
.expect_err("custom deserialization must reject before transport startup");
assert!(format!("{error}").contains("custom config rejection"));
}
#[test]
fn the_bundle_binds_the_model_and_assets_together() {
let bundle = staged_bundle();
let inputs = participant_inputs_for_launch(
bundle.path(),
&ParticipantId::new("drive_motor-front_left_drive").expect("test participant"),
)
.expect("the staged bundle loads");
assert_eq!(inputs.robot().id().as_str(), "rgbd-imu-diff-drive");
assert_eq!(
inputs
.participant()
.component()
.map(|component| component.as_str()),
Some("front_left_drive")
);
assert!(
inputs
.assets()
.read(
&crate::AssetId::new("components/drive_motor/meshes/drive_motor.obj").unwrap()
)
.is_ok()
);
assert!(
inputs
.assets()
.read(&crate::AssetId::new("bin/brain").unwrap())
.is_err()
);
let missing = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../fixture/robot");
assert!(
participant_inputs_for_launch(
&missing,
&ParticipantId::new("drive_motor").expect("test participant"),
)
.is_err(),
"a directory that is not a finalized bundle must fail the launch, not bind nothing"
);
}
}