use crate::{DESCRIPTOR_VERSION, SchemaError, Type, TypeTable, fingerprint_bytes};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
Engine,
Session,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OperationKind {
#[default]
Command,
Query,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Concurrency {
Serial,
Overlapping,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Retry {
Never,
Idempotent,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandPolicy {
pub concurrency: Concurrency,
pub retry: Retry,
pub cancellable: bool,
}
impl Default for CommandPolicy {
fn default() -> Self {
Self {
concurrency: Concurrency::Serial,
retry: Retry::Never,
cancellable: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExternalNames {
pub cli: Vec<String>,
pub mcp: String,
}
impl ExternalNames {
pub fn derive(namespace: &str, name: &str) -> Self {
Self {
cli: vec![namespace.replace('_', "-"), name.replace('_', "-")],
mcp: format!("{namespace}_{name}"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParamSpec {
pub name: String,
#[serde(rename = "type")]
pub ty: Type,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub doc: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandSpec {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub doc: String,
#[serde(default)]
pub kind: OperationKind,
pub scope: Scope,
pub params: Vec<ParamSpec>,
pub result: Type,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<String>,
pub policy: CommandPolicy,
pub external: ExternalNames,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reads: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EventSpec {
pub name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub doc: String,
pub params: Vec<ParamSpec>,
}
impl CommandSpec {
pub fn is_query(&self) -> bool {
self.kind == OperationKind::Query
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StateField {
pub name: String,
#[serde(rename = "type")]
pub ty: Type,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub doc: String,
#[serde(default, skip_serializing_if = "is_false")]
pub content: bool,
}
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtocolDescriptor {
pub descriptor_version: u32,
pub namespace: String,
pub version: u32,
pub types: TypeTable,
pub state: Vec<StateField>,
pub commands: Vec<CommandSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<EventSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub modules: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DescriptorError(pub String);
impl fmt::Display for DescriptorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for DescriptorError {}
impl From<SchemaError> for DescriptorError {
fn from(error: SchemaError) -> Self {
DescriptorError(error.message)
}
}
pub const RESERVED_NAMESPACES: &[&str] = &["bern", "ui", "window", "session", "media", "modules"];
pub const MODULES_NAMESPACE: &str = "modules";
pub fn valid_name(name: &str) -> bool {
name.starts_with(|c: char| c.is_ascii_lowercase())
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
&& !name.ends_with('_')
&& !name.contains("__")
}
impl ProtocolDescriptor {
pub fn command(&self, id: &str) -> Option<(usize, &CommandSpec)> {
self.commands
.iter()
.enumerate()
.find(|(_, spec)| spec.id == id)
}
pub fn command_symbol(&self, command: &CommandSpec) -> String {
match command.id.strip_suffix(&format!(".{}", command.name)) {
Some(namespace) if namespace != self.namespace => {
format!("{namespace}_{}", command.name)
}
_ => command.name.clone(),
}
}
pub fn state_index(&self, name: &str) -> Option<usize> {
self.state.iter().position(|field| field.name == name)
}
pub fn fingerprint(&self) -> u64 {
fingerprint_bytes(
serde_json::to_string(self)
.expect("descriptor serializes")
.as_bytes(),
)
}
pub fn validate(&self) -> Result<(), DescriptorError> {
if self.descriptor_version != DESCRIPTOR_VERSION {
return Err(DescriptorError(format!(
"descriptor version {} is not supported (expected {DESCRIPTOR_VERSION})",
self.descriptor_version
)));
}
if !valid_name(&self.namespace) {
return Err(DescriptorError(format!(
"invalid protocol namespace `{}`; use lower snake case",
self.namespace
)));
}
for namespace in std::iter::once(&self.namespace).chain(&self.modules) {
if !valid_name(namespace) {
return Err(DescriptorError(format!(
"invalid module namespace `{namespace}`; use lower snake case"
)));
}
if RESERVED_NAMESPACES.contains(&namespace.as_str()) {
return Err(DescriptorError(format!(
"protocol namespace `{namespace}` is reserved for framework commands"
)));
}
}
self.types.validate()?;
let mut state_names = BTreeMap::new();
for field in &self.state {
if state_names.insert(&field.name, ()).is_some() {
return Err(DescriptorError(format!(
"state field `{}` is declared twice",
field.name
)));
}
self.types.check_type(&field.ty, &mut Vec::new())?;
}
let mut ids = BTreeMap::new();
for command in &self.commands {
if !valid_name(&command.name) {
return Err(DescriptorError(format!(
"invalid command name `{}`; use lower snake case",
command.name
)));
}
let owner = command
.id
.strip_suffix(&format!(".{}", command.name))
.unwrap_or_default();
let switch = owner == MODULES_NAMESPACE && !self.modules.is_empty();
if owner != self.namespace
&& !self.modules.iter().any(|module| module == owner)
&& !switch
{
return Err(DescriptorError(format!(
"command `{}` must use the id `{}.{}`",
command.name, self.namespace, command.name
)));
}
if ids.insert(command.id.clone(), ()).is_some() {
return Err(DescriptorError(format!(
"command `{}` is registered twice",
command.id
)));
}
let mut params = BTreeMap::new();
for param in &command.params {
if params.insert(¶m.name, ()).is_some() {
return Err(DescriptorError(format!(
"command `{}` repeats parameter `{}`",
command.id, param.name
)));
}
self.types.check_type(¶m.ty, &mut Vec::new())?;
}
self.types.check_type(&command.result, &mut Vec::new())?;
if !command.reads.is_empty() && !command.is_query() {
return Err(DescriptorError(format!(
"command `{}` declares state reads; only queries do",
command.id
)));
}
for field in &command.reads {
if self.state_index(field).is_none() {
return Err(DescriptorError(format!(
"query `{}` reads `{field}`, which is not a state field",
command.id
)));
}
}
}
check_external_names(self.commands.iter())?;
Ok(())
}
}
pub fn check_external_names<'a>(
commands: impl IntoIterator<Item = &'a CommandSpec>,
) -> Result<(), DescriptorError> {
let mut cli = BTreeMap::new();
let mut mcp = BTreeMap::new();
for command in commands {
if let Some(other) = cli.insert(command.external.cli.join(" "), &command.id) {
return Err(DescriptorError(format!(
"commands `{other}` and `{}` map to the same CLI command `{}`",
command.id,
command.external.cli.join(" ")
)));
}
if let Some(other) = mcp.insert(command.external.mcp.clone(), &command.id) {
return Err(DescriptorError(format!(
"commands `{other}` and `{}` map to the same MCP tool `{}`",
command.id, command.external.mcp
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn command(namespace: &str, name: &str) -> CommandSpec {
CommandSpec {
id: format!("{namespace}.{name}"),
name: name.into(),
doc: String::new(),
kind: OperationKind::Command,
scope: Scope::Engine,
params: vec![],
result: Type::Unit,
errors: vec![],
policy: CommandPolicy::default(),
external: ExternalNames::derive(namespace, name),
reads: vec![],
on: None,
}
}
fn descriptor(commands: Vec<CommandSpec>) -> ProtocolDescriptor {
ProtocolDescriptor {
modules: Vec::new(),
events: Vec::new(),
descriptor_version: DESCRIPTOR_VERSION,
namespace: "player".into(),
version: 1,
types: TypeTable::default(),
state: vec![],
commands,
}
}
#[test]
fn external_names_follow_the_documented_mapping() {
let names = ExternalNames::derive("player", "seek_to");
assert_eq!(names.cli, vec!["player", "seek-to"]);
assert_eq!(names.mcp, "player_seek_to");
}
#[test]
fn duplicate_commands_and_name_collisions_fail() {
assert!(
descriptor(vec![command("player", "seek"), command("player", "seek")])
.validate()
.is_err()
);
let mut clash = command("player", "pause");
clash.external.mcp = "player_seek".into();
assert!(
descriptor(vec![command("player", "seek"), clash])
.validate()
.is_err()
);
descriptor(vec![command("player", "seek"), command("player", "pause")])
.validate()
.unwrap();
}
#[test]
fn only_queries_read_declared_state_fields() {
let mut query = command("player", "search");
query.kind = OperationKind::Query;
query.reads = vec!["position_ms".into()];
let mut with_state = descriptor(vec![query.clone()]);
with_state.state.push(StateField {
name: "position_ms".into(),
ty: Type::Int,
doc: String::new(),
content: false,
});
with_state.validate().unwrap();
assert!(descriptor(vec![query.clone()]).validate().is_err());
let mut command_reads = with_state.clone();
command_reads.commands[0].kind = OperationKind::Command;
assert!(command_reads.validate().is_err());
}
#[test]
fn fingerprint_changes_with_catalog() {
let a = descriptor(vec![command("player", "seek")]);
let b = descriptor(vec![command("player", "pause")]);
assert_ne!(a.fingerprint(), b.fingerprint());
assert_eq!(a.fingerprint(), a.clone().fingerprint());
}
}