use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostManifest {
#[serde(default)]
pub externals: Vec<ManifestExternal>,
#[serde(default)]
pub types: Vec<SemanticTypeDef>,
#[serde(default)]
pub markup: Vec<ManifestSpanKind>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestSpanKind {
pub name: String,
#[serde(default)]
pub attrs: Vec<ManifestSpanAttr>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestSpanAttr {
pub name: String,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ty: Option<TypeRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestExternal {
pub name: String,
#[serde(default)]
pub params: Vec<ManifestParam>,
#[serde(default)]
pub returns: TypeRef,
#[serde(default)]
pub kind: ExternalKind,
#[serde(default)]
pub doc: Option<String>,
#[serde(default)]
pub widgets: Vec<ArgGroupWidget>,
#[serde(default)]
pub path: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArgGroupWidget {
pub group: Vec<u32>,
#[serde(rename = "type")]
pub ty: String,
#[serde(default)]
pub surface: Option<String>,
#[serde(default)]
pub context: std::collections::BTreeMap<String, u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestParam {
pub name: String,
#[serde(default)]
pub ty: TypeRef,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SemanticTypeDef {
pub name: String,
pub base: BaseType,
#[serde(default)]
pub constraint: Option<Constraint>,
#[serde(default)]
pub values: Option<ValueSource>,
#[serde(default)]
pub widget: Option<WidgetDecl>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WidgetDecl {
pub kind: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValueItem {
pub value: String,
pub label: String,
#[serde(default)]
pub detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "source")]
pub enum ValueSource {
Static { items: Vec<ValueItem> },
Host,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum Constraint {
Enum { values: Vec<String> },
Regex { pattern: String },
Range {
#[serde(default)]
min: Option<i64>,
#[serde(default)]
max: Option<i64>,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TypeRef(pub String);
impl TypeRef {
#[must_use]
pub fn is_unspecified(&self) -> bool {
self.0.trim().is_empty()
}
#[must_use]
pub fn as_base(&self) -> Option<BaseType> {
BaseType::from_keyword(self.0.trim())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BaseType {
String,
Int,
Float,
Bool,
Void,
Handle,
}
impl BaseType {
#[must_use]
pub fn from_keyword(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"string" => Some(Self::String),
"int" => Some(Self::Int),
"float" => Some(Self::Float),
"bool" => Some(Self::Bool),
"void" => Some(Self::Void),
"handle" => Some(Self::Handle),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExternalKind {
Query,
Effect,
Presentation,
#[default]
Plain,
}
impl ExternalKind {
#[must_use]
pub fn from_tag(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"query" => Some(Self::Query),
"effect" => Some(Self::Effect),
"presentation" => Some(Self::Presentation),
"plain" => Some(Self::Plain),
_ => None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DocBlock {
pub doc: Option<String>,
pub params: Vec<(String, TypeRef)>,
pub returns: Option<TypeRef>,
pub kind: Option<ExternalKind>,
}
#[cfg(test)]
mod manifest_field_name_tests {
use brink_format::manifest_field_names::{EXTERNALS, NAME};
use super::{HostManifest, ManifestExternal};
#[test]
fn serialized_wire_keys_match_the_shared_field_name_constants() {
let manifest = HostManifest {
markup: Vec::new(),
externals: vec![ManifestExternal {
name: "has".to_string(),
params: vec![],
returns: super::TypeRef::default(),
kind: super::ExternalKind::default(),
doc: None,
widgets: vec![],
path: vec![],
}],
types: vec![],
};
let json = serde_json::to_string(&manifest).expect("serialize");
assert!(
json.contains(&format!("\"{EXTERNALS}\":")),
"top-level wrapper key drifted from manifest_field_names::EXTERNALS: {json}"
);
assert!(
json.contains(&format!("\"{NAME}\":\"has\"")),
"external entry's name key drifted from manifest_field_names::NAME: {json}"
);
let parsed: HostManifest = serde_json::from_str(&json).expect("re-parse");
assert_eq!(parsed, manifest);
}
}
#[cfg(test)]
mod value_source_tests {
use super::{BaseType, SemanticTypeDef, ValueSource};
#[test]
fn static_value_source_json_roundtrip() {
let json = r#"{
"name": "switch_id",
"base": "int",
"values": { "source": "static", "items": [
{ "value": "5", "label": "HarborGate", "detail": "Switch #5" },
{ "value": "9", "label": "Vault" }
] }
}"#;
let def: SemanticTypeDef = serde_json::from_str(json).expect("parse");
let items = match def.values {
Some(ValueSource::Static { items }) => items,
_ => Vec::new(),
};
assert_eq!(items.len(), 2, "two static items parsed");
assert_eq!(items[0].value, "5");
assert_eq!(items[0].label, "HarborGate");
assert_eq!(items[0].detail.as_deref(), Some("Switch #5"));
assert_eq!(items[1].detail, None);
}
#[test]
fn host_value_source_and_omitted_values_parse() {
let host: SemanticTypeDef = serde_json::from_str(
r#"{ "name": "item_id", "base": "int", "values": { "source": "host" } }"#,
)
.expect("parse host");
assert!(matches!(host.values, Some(ValueSource::Host)));
let none: SemanticTypeDef =
serde_json::from_str(r#"{ "name": "x", "base": "int" }"#).expect("parse bare");
assert!(none.values.is_none());
}
#[test]
fn handle_base_json_roundtrip() {
let def: SemanticTypeDef =
serde_json::from_str(r#"{ "name": "AudioInstance", "base": "handle" }"#)
.expect("parse handle base");
assert_eq!(def.name, "AudioInstance");
assert_eq!(def.base, BaseType::Handle);
assert_eq!(BaseType::from_keyword("handle"), Some(BaseType::Handle));
}
}
#[cfg(test)]
mod doc_example_tests {
use super::{ExternalKind, HostManifest, ManifestExternal, ManifestParam, TypeRef};
#[test]
fn tier_1_doc_example_roundtrips_through_manifest_param() {
let json = r#"
{ "externals": [
{ "name": "has", "params": [{"name": "item", "ty": "string"}], "returns": "bool", "kind": "query" },
{ "name": "camera", "params": [{"name": "target", "ty": "string"}], "returns": "void", "kind": "presentation" },
{ "name": "grant", "params": [{"name": "item", "ty": "string"}], "returns": "void", "kind": "effect" }
] }
"#;
let manifest: HostManifest = serde_json::from_str(json).expect("parse doc example");
assert_eq!(manifest.externals.len(), 3);
let has = &manifest.externals[0];
assert_eq!(has.name, "has");
assert_eq!(
has.params,
vec![ManifestParam {
name: "item".to_string(),
ty: TypeRef("string".to_string()),
}]
);
assert_eq!(has.kind, ExternalKind::Query);
let serialized = serde_json::to_string(&manifest).expect("serialize");
let round_tripped: HostManifest =
serde_json::from_str(&serialized).expect("re-parse serialized manifest");
assert_eq!(manifest, round_tripped);
assert!(serialized.contains(r#""name":"item""#));
assert!(serialized.contains(r#""ty":"string""#));
let camera_params = &manifest.externals[1].params;
assert_eq!(camera_params[0].name, "target");
let set_move_route: ManifestExternal = serde_json::from_str(
r#"{ "name": "set_move_route", "params": [{"name": "actor", "ty": "int"}],
"returns": "void", "kind": "effect", "path": ["Map", "Movement"] }"#,
)
.expect("parse path example");
assert_eq!(
set_move_route.params,
vec![ManifestParam {
name: "actor".to_string(),
ty: TypeRef("int".to_string()),
}]
);
assert_eq!(
set_move_route.path,
vec!["Map".to_string(), "Movement".to_string()]
);
}
#[test]
fn markup_vocabulary_doc_example_roundtrips() {
let json = r#"
{ "markup": [
{ "name": "wave", "attrs": [{ "name": "amount" }] },
{ "name": "b" },
{ "name": "sfx", "attrs": [{ "name": "name" }, { "name": "volume", "required": true }] }
] }
"#;
let manifest: super::HostManifest = serde_json::from_str(json).expect("parse doc example");
assert_eq!(manifest.markup.len(), 3);
assert_eq!(manifest.markup[0].name, "wave");
assert_eq!(
manifest.markup[0].attrs,
vec![super::ManifestSpanAttr {
name: "amount".to_string(),
required: false,
ty: None,
}]
);
assert!(manifest.markup[1].attrs.is_empty());
assert!(!manifest.markup[2].attrs[0].required);
assert!(manifest.markup[2].attrs[1].required);
assert!(manifest.externals.is_empty());
assert!(manifest.types.is_empty());
let serialized = serde_json::to_string(&manifest).expect("serialize");
let round_tripped: super::HostManifest =
serde_json::from_str(&serialized).expect("re-parse serialized manifest");
assert_eq!(manifest, round_tripped);
assert!(
serialized.contains(
r#""markup":[{"name":"wave","attrs":[{"name":"amount","required":false}]}"#
)
);
}
#[test]
fn pre_1997_bare_attribute_name_array_is_rejected() {
let json = r#"{ "markup": [{ "name": "wave", "attrs": ["amount"] }] }"#;
assert!(
serde_json::from_str::<super::HostManifest>(json).is_err(),
"the pre-#1997 bare attribute-name form is deliberately rejected"
);
}
}