Skip to main content

bern_protocol/
descriptor.rs

1use crate::{DESCRIPTOR_VERSION, SchemaError, Type, TypeTable, fingerprint_bytes};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::fmt;
5
6/// Whether a command changes engine state or acts on one UI session.
7#[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/// Commands perform actions; queries only read and never change state.
15#[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    /// Requests run one after another in arrival order.
27    Serial,
28    /// Requests start in arrival order, after the commands queued before
29    /// them, but answer independently and possibly out of order. Queries
30    /// whose providers answer asynchronously use it.
31    Overlapping,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum Retry {
37    /// Resending after an unknown outcome may repeat side effects.
38    Never,
39    /// Repeating the same request leaves the same final state.
40    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/// Explicit, collision-checked names for entries other than the UI.
61#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ExternalNames {
63    /// Sub-command words, for example `["player", "seek"]`.
64    pub cli: Vec<String>,
65    /// MCP tool name, for example `player_seek`.
66    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    /// Stable identifier, `namespace.name`.
90    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    /// Error codes the handler may return besides the generic ones.
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub errors: Vec<String>,
102    pub policy: CommandPolicy,
103    pub external: ExternalNames,
104    /// State fields a query reads. A query's answer is stale once one of
105    /// them changes, so live subscriptions request it again.
106    #[serde(default, skip_serializing_if = "Vec::is_empty")]
107    pub reads: Vec<String>,
108    /// For a command answering another module's event: the event, as
109    /// `module.event`. It runs after each command emitting that event.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub on: Option<String>,
112}
113
114/// Something a module reports so the modules depending on it can react,
115/// such as `playback.opened`.
116#[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    /// `#[content]`: text that is data, shown as it is (see
138    /// [`crate::FieldDef::content`]).
139    #[serde(default, skip_serializing_if = "is_false")]
140    pub content: bool,
141}
142
143fn is_false(value: &bool) -> bool {
144    !*value
145}
146
147/// The complete, versioned description of one application protocol:
148/// its types, read-only state snapshot and command catalog.
149#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
150pub struct ProtocolDescriptor {
151    pub descriptor_version: u32,
152    pub namespace: String,
153    /// Catalog version chosen by the application.
154    pub version: u32,
155    pub types: TypeTable,
156    pub state: Vec<StateField>,
157    pub commands: Vec<CommandSpec>,
158    /// Events this protocol emits, in declaration order.
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub events: Vec<EventSpec>,
161    /// Namespaces of the modules composed into this catalog, in order.
162    /// Their commands keep their own namespace in their ids, and each
163    /// module's state is the state field of the same name.
164    #[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
185/// Namespaces used by framework-provided session, window and media commands.
186pub const RESERVED_NAMESPACES: &[&str] = &["bern", "ui", "window", "session", "media", "modules"];
187
188/// Namespace of the commands switching optional modules on and off.
189pub 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    /// A name for `command` that is unique in this catalog, for generated
209    /// code: the command's own name in the application's namespace, the
210    /// namespace and name joined by `_` in a module's, such as
211    /// `downloads_add`.
212    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    /// Stable fingerprint of the full descriptor. Peers compare it during
226    /// the handshake so a stale client cannot silently use another catalog.
227    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(&param.name, ()).is_some() {
302                    return Err(DescriptorError(format!(
303                        "command `{}` repeats parameter `{}`",
304                        command.id, param.name
305                    )));
306                }
307                self.types.check_type(&param.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
330/// Reject catalogs where two commands would share a CLI path or MCP tool.
331pub 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        // Unknown field.
428        assert!(descriptor(vec![query.clone()]).validate().is_err());
429        // A command cannot declare reads.
430        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}