1use crate::{DESCRIPTOR_VERSION, SchemaError, Type, TypeTable, fingerprint_bytes};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::fmt;
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum Scope {
10 Engine,
11 Session,
12}
13
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum OperationKind {
18 #[default]
19 Command,
20 Query,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum Concurrency {
26 Serial,
28 Overlapping,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum Retry {
37 Never,
39 Idempotent,
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
44pub struct CommandPolicy {
45 pub concurrency: Concurrency,
46 pub retry: Retry,
47 pub cancellable: bool,
48}
49
50impl Default for CommandPolicy {
51 fn default() -> Self {
52 Self {
53 concurrency: Concurrency::Serial,
54 retry: Retry::Never,
55 cancellable: false,
56 }
57 }
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ExternalNames {
63 pub cli: Vec<String>,
65 pub mcp: String,
67}
68
69impl ExternalNames {
70 pub fn derive(namespace: &str, name: &str) -> Self {
71 Self {
72 cli: vec![namespace.replace('_', "-"), name.replace('_', "-")],
73 mcp: format!("{namespace}_{name}"),
74 }
75 }
76}
77
78#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79pub struct ParamSpec {
80 pub name: String,
81 #[serde(rename = "type")]
82 pub ty: Type,
83 #[serde(default, skip_serializing_if = "String::is_empty")]
84 pub doc: String,
85}
86
87#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
88pub struct CommandSpec {
89 pub id: String,
91 pub name: String,
92 #[serde(default, skip_serializing_if = "String::is_empty")]
93 pub doc: String,
94 #[serde(default)]
95 pub kind: OperationKind,
96 pub scope: Scope,
97 pub params: Vec<ParamSpec>,
98 pub result: Type,
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub errors: Vec<String>,
102 pub policy: CommandPolicy,
103 pub external: ExternalNames,
104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
107 pub reads: Vec<String>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub on: Option<String>,
112}
113
114#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117pub struct EventSpec {
118 pub name: String,
119 #[serde(default, skip_serializing_if = "String::is_empty")]
120 pub doc: String,
121 pub params: Vec<ParamSpec>,
122}
123
124impl CommandSpec {
125 pub fn is_query(&self) -> bool {
126 self.kind == OperationKind::Query
127 }
128}
129
130#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
131pub struct StateField {
132 pub name: String,
133 #[serde(rename = "type")]
134 pub ty: Type,
135 #[serde(default, skip_serializing_if = "String::is_empty")]
136 pub doc: String,
137 #[serde(default, skip_serializing_if = "is_false")]
140 pub content: bool,
141}
142
143fn is_false(value: &bool) -> bool {
144 !*value
145}
146
147#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
150pub struct ProtocolDescriptor {
151 pub descriptor_version: u32,
152 pub namespace: String,
153 pub version: u32,
155 pub types: TypeTable,
156 pub state: Vec<StateField>,
157 pub commands: Vec<CommandSpec>,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 pub events: Vec<EventSpec>,
161 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub modules: Vec<String>,
166}
167
168#[derive(Clone, Debug, PartialEq, Eq)]
169pub struct DescriptorError(pub String);
170
171impl fmt::Display for DescriptorError {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 f.write_str(&self.0)
174 }
175}
176
177impl std::error::Error for DescriptorError {}
178
179impl From<SchemaError> for DescriptorError {
180 fn from(error: SchemaError) -> Self {
181 DescriptorError(error.message)
182 }
183}
184
185pub const RESERVED_NAMESPACES: &[&str] = &["bern", "ui", "window", "session", "media", "modules"];
187
188pub const MODULES_NAMESPACE: &str = "modules";
190
191pub fn valid_name(name: &str) -> bool {
192 name.starts_with(|c: char| c.is_ascii_lowercase())
193 && name
194 .chars()
195 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
196 && !name.ends_with('_')
197 && !name.contains("__")
198}
199
200impl ProtocolDescriptor {
201 pub fn command(&self, id: &str) -> Option<(usize, &CommandSpec)> {
202 self.commands
203 .iter()
204 .enumerate()
205 .find(|(_, spec)| spec.id == id)
206 }
207
208 pub fn command_symbol(&self, command: &CommandSpec) -> String {
213 match command.id.strip_suffix(&format!(".{}", command.name)) {
214 Some(namespace) if namespace != self.namespace => {
215 format!("{namespace}_{}", command.name)
216 }
217 _ => command.name.clone(),
218 }
219 }
220
221 pub fn state_index(&self, name: &str) -> Option<usize> {
222 self.state.iter().position(|field| field.name == name)
223 }
224
225 pub fn fingerprint(&self) -> u64 {
228 fingerprint_bytes(
229 serde_json::to_string(self)
230 .expect("descriptor serializes")
231 .as_bytes(),
232 )
233 }
234
235 pub fn validate(&self) -> Result<(), DescriptorError> {
236 if self.descriptor_version != DESCRIPTOR_VERSION {
237 return Err(DescriptorError(format!(
238 "descriptor version {} is not supported (expected {DESCRIPTOR_VERSION})",
239 self.descriptor_version
240 )));
241 }
242 if !valid_name(&self.namespace) {
243 return Err(DescriptorError(format!(
244 "invalid protocol namespace `{}`; use lower snake case",
245 self.namespace
246 )));
247 }
248 for namespace in std::iter::once(&self.namespace).chain(&self.modules) {
249 if !valid_name(namespace) {
250 return Err(DescriptorError(format!(
251 "invalid module namespace `{namespace}`; use lower snake case"
252 )));
253 }
254 if RESERVED_NAMESPACES.contains(&namespace.as_str()) {
255 return Err(DescriptorError(format!(
256 "protocol namespace `{namespace}` is reserved for framework commands"
257 )));
258 }
259 }
260 self.types.validate()?;
261 let mut state_names = BTreeMap::new();
262 for field in &self.state {
263 if state_names.insert(&field.name, ()).is_some() {
264 return Err(DescriptorError(format!(
265 "state field `{}` is declared twice",
266 field.name
267 )));
268 }
269 self.types.check_type(&field.ty, &mut Vec::new())?;
270 }
271 let mut ids = BTreeMap::new();
272 for command in &self.commands {
273 if !valid_name(&command.name) {
274 return Err(DescriptorError(format!(
275 "invalid command name `{}`; use lower snake case",
276 command.name
277 )));
278 }
279 let owner = command
280 .id
281 .strip_suffix(&format!(".{}", command.name))
282 .unwrap_or_default();
283 let switch = owner == MODULES_NAMESPACE && !self.modules.is_empty();
284 if owner != self.namespace
285 && !self.modules.iter().any(|module| module == owner)
286 && !switch
287 {
288 return Err(DescriptorError(format!(
289 "command `{}` must use the id `{}.{}`",
290 command.name, self.namespace, command.name
291 )));
292 }
293 if ids.insert(command.id.clone(), ()).is_some() {
294 return Err(DescriptorError(format!(
295 "command `{}` is registered twice",
296 command.id
297 )));
298 }
299 let mut params = BTreeMap::new();
300 for param in &command.params {
301 if params.insert(¶m.name, ()).is_some() {
302 return Err(DescriptorError(format!(
303 "command `{}` repeats parameter `{}`",
304 command.id, param.name
305 )));
306 }
307 self.types.check_type(¶m.ty, &mut Vec::new())?;
308 }
309 self.types.check_type(&command.result, &mut Vec::new())?;
310 if !command.reads.is_empty() && !command.is_query() {
311 return Err(DescriptorError(format!(
312 "command `{}` declares state reads; only queries do",
313 command.id
314 )));
315 }
316 for field in &command.reads {
317 if self.state_index(field).is_none() {
318 return Err(DescriptorError(format!(
319 "query `{}` reads `{field}`, which is not a state field",
320 command.id
321 )));
322 }
323 }
324 }
325 check_external_names(self.commands.iter())?;
326 Ok(())
327 }
328}
329
330pub fn check_external_names<'a>(
332 commands: impl IntoIterator<Item = &'a CommandSpec>,
333) -> Result<(), DescriptorError> {
334 let mut cli = BTreeMap::new();
335 let mut mcp = BTreeMap::new();
336 for command in commands {
337 if let Some(other) = cli.insert(command.external.cli.join(" "), &command.id) {
338 return Err(DescriptorError(format!(
339 "commands `{other}` and `{}` map to the same CLI command `{}`",
340 command.id,
341 command.external.cli.join(" ")
342 )));
343 }
344 if let Some(other) = mcp.insert(command.external.mcp.clone(), &command.id) {
345 return Err(DescriptorError(format!(
346 "commands `{other}` and `{}` map to the same MCP tool `{}`",
347 command.id, command.external.mcp
348 )));
349 }
350 }
351 Ok(())
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 fn command(namespace: &str, name: &str) -> CommandSpec {
359 CommandSpec {
360 id: format!("{namespace}.{name}"),
361 name: name.into(),
362 doc: String::new(),
363 kind: OperationKind::Command,
364 scope: Scope::Engine,
365 params: vec![],
366 result: Type::Unit,
367 errors: vec![],
368 policy: CommandPolicy::default(),
369 external: ExternalNames::derive(namespace, name),
370 reads: vec![],
371 on: None,
372 }
373 }
374
375 fn descriptor(commands: Vec<CommandSpec>) -> ProtocolDescriptor {
376 ProtocolDescriptor {
377 modules: Vec::new(),
378 events: Vec::new(),
379 descriptor_version: DESCRIPTOR_VERSION,
380 namespace: "player".into(),
381 version: 1,
382 types: TypeTable::default(),
383 state: vec![],
384 commands,
385 }
386 }
387
388 #[test]
389 fn external_names_follow_the_documented_mapping() {
390 let names = ExternalNames::derive("player", "seek_to");
391 assert_eq!(names.cli, vec!["player", "seek-to"]);
392 assert_eq!(names.mcp, "player_seek_to");
393 }
394
395 #[test]
396 fn duplicate_commands_and_name_collisions_fail() {
397 assert!(
398 descriptor(vec![command("player", "seek"), command("player", "seek")])
399 .validate()
400 .is_err()
401 );
402 let mut clash = command("player", "pause");
403 clash.external.mcp = "player_seek".into();
404 assert!(
405 descriptor(vec![command("player", "seek"), clash])
406 .validate()
407 .is_err()
408 );
409 descriptor(vec![command("player", "seek"), command("player", "pause")])
410 .validate()
411 .unwrap();
412 }
413
414 #[test]
415 fn only_queries_read_declared_state_fields() {
416 let mut query = command("player", "search");
417 query.kind = OperationKind::Query;
418 query.reads = vec!["position_ms".into()];
419 let mut with_state = descriptor(vec![query.clone()]);
420 with_state.state.push(StateField {
421 name: "position_ms".into(),
422 ty: Type::Int,
423 doc: String::new(),
424 content: false,
425 });
426 with_state.validate().unwrap();
427 assert!(descriptor(vec![query.clone()]).validate().is_err());
429 let mut command_reads = with_state.clone();
431 command_reads.commands[0].kind = OperationKind::Command;
432 assert!(command_reads.validate().is_err());
433 }
434
435 #[test]
436 fn fingerprint_changes_with_catalog() {
437 let a = descriptor(vec![command("player", "seek")]);
438 let b = descriptor(vec![command("player", "pause")]);
439 assert_ne!(a.fingerprint(), b.fingerprint());
440 assert_eq!(a.fingerprint(), a.clone().fingerprint());
441 }
442}