Skip to main content

bern_protocol/
compose.rs

1//! One catalog from an application's shell protocol and its modules.
2//!
3//! Each module keeps its own namespace, so its command ids stay
4//! `module.command`. Its state becomes one field of the application state,
5//! named after the module, whose type is a record synthesized from the
6//! module's state fields. Type names are unique across the application:
7//! two different definitions with one name are an error.
8//!
9//! The build tool composes the descriptors it reads from source and the
10//! running engine composes the ones the protocol macros generated, with
11//! this same function, so both sides arrive at the same fingerprint.
12
13use crate::{
14    CommandPolicy, CommandSpec, DescriptorError, ExternalNames, FieldDef, MODULES_NAMESPACE,
15    OperationKind, ParamSpec, ProtocolDescriptor, RecordDef, Retry, Scope, StateField, Type,
16    valid_name,
17};
18
19/// One module of an application: its catalog, and whether it can be
20/// switched off at run time.
21#[derive(Clone, Copy, Debug)]
22pub struct ModulePart<'a> {
23    pub descriptor: &'a ProtocolDescriptor,
24    pub optional: bool,
25}
26
27/// Record type of the `modules` state field: one `bool` per optional
28/// module, true while the module runs.
29pub const MODULES_STATE_TYPE: &str = "Modules";
30
31/// Name of the command switching an optional module: `modules.set_enabled`.
32pub const SET_ENABLED: &str = "set_enabled";
33
34/// Name of the record holding a module's state: `downloads` gives
35/// `DownloadsState`, `network_sources` gives `NetworkSourcesState`.
36pub fn module_state_type(namespace: &str) -> String {
37    let mut name = String::new();
38    for word in namespace.split('_') {
39        let mut chars = word.chars();
40        if let Some(first) = chars.next() {
41            name.extend(first.to_uppercase());
42            name.push_str(chars.as_str());
43        }
44    }
45    name.push_str("State");
46    name
47}
48
49/// The application catalog: the shell's state and commands first, then
50/// each module in order. Module query reads name the module's own fields;
51/// in the application they read the module's state field as a whole.
52///
53/// With optional modules the catalog ends with the `modules` state field,
54/// whose record holds a `bool` per optional module, and the command
55/// `modules.set_enabled(id, enabled)`, which returns the ids of the modules
56/// it switched, dependencies included.
57pub fn compose(
58    shell: &ProtocolDescriptor,
59    parts: &[ModulePart<'_>],
60) -> Result<ProtocolDescriptor, DescriptorError> {
61    let modules: Vec<&ProtocolDescriptor> = parts.iter().map(|part| part.descriptor).collect();
62    let mut composed = shell.clone();
63    // In the application every event is named `namespace.event`.
64    let qualify = |namespace: &str, events: &[crate::EventSpec]| {
65        events
66            .iter()
67            .map(|event| crate::EventSpec {
68                name: format!("{namespace}.{}", event.name),
69                ..event.clone()
70            })
71            .collect::<Vec<_>>()
72    };
73    composed.events = qualify(&shell.namespace, &shell.events);
74    for module in &modules {
75        let namespace = &module.namespace;
76        if !valid_name(namespace) {
77            return Err(DescriptorError(format!(
78                "invalid module namespace `{namespace}`; use lower snake case"
79            )));
80        }
81        if namespace == &shell.namespace
82            || modules
83                .iter()
84                .filter(|other| &other.namespace == namespace)
85                .count()
86                > 1
87        {
88            return Err(DescriptorError(format!(
89                "namespace `{namespace}` is used by more than one protocol"
90            )));
91        }
92        if composed.state_index(namespace).is_some() {
93            return Err(DescriptorError(format!(
94                "state field `{namespace}` of `{}` has the name of a module",
95                shell.namespace
96            )));
97        }
98        composed
99            .types
100            .merge(&module.types)
101            .map_err(|error| DescriptorError(format!("module `{namespace}`: {}", error.message)))?;
102        let record = module_state_type(namespace);
103        if composed.types.enum_def(&record).is_some()
104            || composed.types.record_def(&record).is_some()
105        {
106            return Err(DescriptorError(format!(
107                "type `{record}` is declared by the application; the state of module `{namespace}` uses that name"
108            )));
109        }
110        composed.types.records.push(RecordDef {
111            name: record.clone(),
112            fields: module
113                .state
114                .iter()
115                .map(|field| FieldDef {
116                    name: field.name.clone(),
117                    ty: field.ty.clone(),
118                    doc: field.doc.clone(),
119                    content: field.content,
120                })
121                .collect(),
122        });
123        composed.state.push(StateField {
124            name: namespace.clone(),
125            ty: Type::Record { name: record },
126            doc: format!("State of the `{namespace}` module."),
127            content: false,
128        });
129        if !module.modules.is_empty() {
130            return Err(DescriptorError(format!(
131                "module `{namespace}` is itself composed; modules do not nest"
132            )));
133        }
134        composed.modules.push(namespace.clone());
135        composed.events.extend(qualify(namespace, &module.events));
136        for command in &module.commands {
137            let mut command = command.clone();
138            if !command.reads.is_empty() {
139                command.reads = vec![namespace.clone()];
140            }
141            composed.commands.push(command);
142        }
143    }
144    // A command answering an event takes exactly the event's parameters.
145    for command in &composed.commands {
146        let Some(on) = &command.on else {
147            continue;
148        };
149        let Some(event) = composed.events.iter().find(|event| &event.name == on) else {
150            return Err(DescriptorError(format!(
151                "command `{}` answers `{on}`, which no protocol of the application emits",
152                command.id
153            )));
154        };
155        let wanted: Vec<_> = event.params.iter().map(|p| (&p.name, &p.ty)).collect();
156        let given: Vec<_> = command.params.iter().map(|p| (&p.name, &p.ty)).collect();
157        if wanted != given {
158            return Err(DescriptorError(format!(
159                "command `{}` answers `{on}`, so it takes the event's parameters ({})",
160                command.id,
161                event
162                    .params
163                    .iter()
164                    .map(|param| format!("{}: {}", param.name, param.ty.display()))
165                    .collect::<Vec<_>>()
166                    .join(", ")
167            )));
168        }
169    }
170    let optional: Vec<&str> = parts
171        .iter()
172        .filter(|part| part.optional)
173        .map(|part| part.descriptor.namespace.as_str())
174        .collect();
175    if !optional.is_empty() {
176        if composed.state_index(MODULES_NAMESPACE).is_some()
177            || composed.types.record_def(MODULES_STATE_TYPE).is_some()
178            || composed.types.enum_def(MODULES_STATE_TYPE).is_some()
179        {
180            return Err(DescriptorError(format!(
181                "the application declares `{MODULES_NAMESPACE}` or `{MODULES_STATE_TYPE}`, which optional modules use"
182            )));
183        }
184        composed.types.records.push(RecordDef {
185            name: MODULES_STATE_TYPE.into(),
186            fields: optional
187                .iter()
188                .map(|namespace| FieldDef {
189                    name: (*namespace).into(),
190                    ty: Type::Bool,
191                    doc: format!("Whether the `{namespace}` module runs."),
192                    content: false,
193                })
194                .collect(),
195        });
196        composed.state.push(StateField {
197            name: MODULES_NAMESPACE.into(),
198            ty: Type::Record {
199                name: MODULES_STATE_TYPE.into(),
200            },
201            doc: "Which optional modules run.".into(),
202            content: false,
203        });
204        composed.commands.push(CommandSpec {
205            id: format!("{MODULES_NAMESPACE}.{SET_ENABLED}"),
206            name: SET_ENABLED.into(),
207            doc: "Switch an optional module on or off. Switching one on switches on the modules it depends on; switching one off switches off the modules depending on it. Returns the ids of every module switched.".into(),
208            kind: OperationKind::Command,
209            scope: Scope::Engine,
210            params: vec![
211                ParamSpec {
212                    name: "id".into(),
213                    ty: Type::Text,
214                    doc: "Module id".into(),
215                },
216                ParamSpec {
217                    name: "enabled".into(),
218                    ty: Type::Bool,
219                    doc: "Whether the module runs".into(),
220                },
221            ],
222            result: Type::list(Type::Text),
223            errors: vec!["unknown_module".into(), "not_optional".into()],
224            policy: CommandPolicy {
225                retry: Retry::Idempotent,
226                ..CommandPolicy::default()
227            },
228            external: ExternalNames::derive(MODULES_NAMESPACE, SET_ENABLED),
229            reads: vec![],
230            on: None,
231        });
232    }
233    composed
234        .validate()
235        .map_err(|error| DescriptorError(format!("the application catalog is invalid: {error}")))?;
236    Ok(composed)
237}
238
239/// Where each part's commands start in the composed catalog: the shell at
240/// 0, then every module in order. `modules.set_enabled`, if any, follows.
241pub fn command_offsets(shell: &ProtocolDescriptor, parts: &[ModulePart<'_>]) -> Vec<usize> {
242    let mut offsets = vec![0];
243    let mut next = shell.commands.len();
244    for part in parts {
245        offsets.push(next);
246        next += part.descriptor.commands.len();
247    }
248    offsets
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::{
255        CommandPolicy, CommandSpec, DESCRIPTOR_VERSION, ExternalNames, OperationKind, Scope,
256        TypeTable,
257    };
258
259    fn command(namespace: &str, name: &str) -> CommandSpec {
260        CommandSpec {
261            id: format!("{namespace}.{name}"),
262            name: name.into(),
263            doc: String::new(),
264            kind: OperationKind::Command,
265            scope: Scope::Engine,
266            params: vec![],
267            result: Type::Unit,
268            errors: vec![],
269            policy: CommandPolicy::default(),
270            external: ExternalNames::derive(namespace, name),
271            reads: vec![],
272            on: None,
273        }
274    }
275
276    fn part(descriptor: &ProtocolDescriptor) -> ModulePart<'_> {
277        ModulePart {
278            descriptor,
279            optional: false,
280        }
281    }
282
283    fn protocol(namespace: &str, fields: &[&str], commands: &[&str]) -> ProtocolDescriptor {
284        ProtocolDescriptor {
285            descriptor_version: DESCRIPTOR_VERSION,
286            namespace: namespace.into(),
287            version: 1,
288            types: TypeTable::default(),
289            state: fields
290                .iter()
291                .map(|name| StateField {
292                    name: (*name).into(),
293                    ty: Type::Int,
294                    doc: String::new(),
295                    content: false,
296                })
297                .collect(),
298            commands: commands
299                .iter()
300                .map(|name| command(namespace, name))
301                .collect(),
302            modules: Vec::new(),
303            events: Vec::new(),
304        }
305    }
306
307    #[test]
308    fn modules_become_state_records_and_keep_their_command_ids() {
309        let shell = protocol("app", &["count"], &["reset"]);
310        let downloads = protocol("downloads", &["active", "done"], &["add", "remove"]);
311        let library = protocol("network_library", &["items"], &["add"]);
312        let composed = compose(&shell, &[part(&downloads), part(&library)]).unwrap();
313        let names: Vec<_> = composed.state.iter().map(|f| f.name.as_str()).collect();
314        assert_eq!(names, ["count", "downloads", "network_library"]);
315        assert_eq!(
316            composed.state[1].ty,
317            Type::Record {
318                name: "DownloadsState".into()
319            }
320        );
321        let record = composed.types.record_def("NetworkLibraryState").unwrap();
322        assert_eq!(record.fields[0].name, "items");
323        let ids: Vec<_> = composed.commands.iter().map(|c| c.id.as_str()).collect();
324        assert_eq!(
325            ids,
326            [
327                "app.reset",
328                "downloads.add",
329                "downloads.remove",
330                "network_library.add"
331            ]
332        );
333        assert_eq!(
334            command_offsets(&shell, &[part(&downloads), part(&library)]),
335            [0, 1, 3]
336        );
337    }
338
339    #[test]
340    fn query_reads_name_the_module_field() {
341        let shell = protocol("app", &[], &[]);
342        let mut downloads = protocol("downloads", &["active"], &[]);
343        let mut search = command("downloads", "search");
344        search.kind = OperationKind::Query;
345        search.reads = vec!["active".into()];
346        downloads.commands.push(search);
347        let composed = compose(&shell, &[part(&downloads)]).unwrap();
348        assert_eq!(composed.commands[0].reads, ["downloads"]);
349    }
350
351    #[test]
352    fn clashing_namespaces_fields_and_types_fail() {
353        let shell = protocol("app", &["downloads"], &[]);
354        let downloads = protocol("downloads", &[], &[]);
355        assert!(compose(&shell, &[part(&downloads)]).is_err());
356        let shell = protocol("app", &[], &[]);
357        assert!(compose(&shell, &[part(&downloads), part(&downloads)]).is_err());
358        assert!(compose(&shell, &[part(&protocol("app", &[], &[]))]).is_err());
359
360        let mut a = protocol("a", &[], &[]);
361        a.types.records.push(RecordDef {
362            name: "Item".into(),
363            fields: vec![],
364        });
365        let mut b = protocol("b", &[], &[]);
366        b.types.records.push(RecordDef {
367            name: "Item".into(),
368            fields: vec![FieldDef {
369                name: "id".into(),
370                ty: Type::Int,
371                doc: String::new(),
372                content: false,
373            }],
374        });
375        assert!(compose(&shell, &[part(&a), part(&b)]).is_err());
376
377        let mut shell = protocol("app", &[], &[]);
378        shell.types.records.push(RecordDef {
379            name: "AState".into(),
380            fields: vec![],
381        });
382        assert!(compose(&shell, &[part(&a)]).is_err());
383    }
384
385    #[test]
386    fn optional_modules_add_their_switch() {
387        let shell = protocol("app", &[], &["reset"]);
388        let notes = protocol("notes", &["count"], &["add"]);
389        let tags = protocol("tags", &[], &[]);
390        let composed = compose(
391            &shell,
392            &[
393                part(&notes),
394                ModulePart {
395                    descriptor: &tags,
396                    optional: true,
397                },
398            ],
399        )
400        .unwrap();
401        let fields: Vec<_> = composed.state.iter().map(|f| f.name.as_str()).collect();
402        assert_eq!(fields, ["notes", "tags", "modules"]);
403        let switches = composed.types.record_def(MODULES_STATE_TYPE).unwrap();
404        assert_eq!(switches.fields.len(), 1);
405        assert_eq!(switches.fields[0].name, "tags");
406        let last = composed.commands.last().unwrap();
407        assert_eq!(last.id, "modules.set_enabled");
408        assert_eq!(last.external.cli, ["modules", "set-enabled"]);
409        // Without optional modules nothing is added.
410        let plain = compose(&shell, &[part(&notes)]).unwrap();
411        assert!(plain.state_index("modules").is_none());
412    }
413
414    #[test]
415    fn answers_take_the_event_parameters() {
416        let shell = protocol("app", &[], &[]);
417        let mut notes = protocol("notes", &[], &["add"]);
418        let param = |name: &str| crate::ParamSpec {
419            name: name.into(),
420            ty: Type::Int,
421            doc: String::new(),
422        };
423        notes.events.push(crate::EventSpec {
424            name: "added".into(),
425            doc: String::new(),
426            params: vec![param("id")],
427        });
428        let mut tags = protocol("tags", &[], &["label"]);
429        tags.commands[0].on = Some("notes.added".into());
430        tags.commands[0].params = vec![param("id")];
431        let composed = compose(&shell, &[part(&notes), part(&tags)]).unwrap();
432        assert_eq!(composed.events[0].name, "notes.added");
433        // Other parameters, or an event nobody emits, are refused.
434        tags.commands[0].params = vec![param("note")];
435        assert!(compose(&shell, &[part(&notes), part(&tags)]).is_err());
436        tags.commands[0].params = vec![param("id")];
437        tags.commands[0].on = Some("notes.removed".into());
438        assert!(compose(&shell, &[part(&notes), part(&tags)]).is_err());
439    }
440
441    #[test]
442    fn state_type_names_are_upper_camel() {
443        assert_eq!(module_state_type("downloads"), "DownloadsState");
444        assert_eq!(module_state_type("network_sources"), "NetworkSourcesState");
445    }
446}