1use crate::{
14 CommandPolicy, CommandSpec, DescriptorError, ExternalNames, FieldDef, MODULES_NAMESPACE,
15 OperationKind, ParamSpec, ProtocolDescriptor, RecordDef, Retry, Scope, StateField, Type,
16 valid_name,
17};
18
19#[derive(Clone, Copy, Debug)]
22pub struct ModulePart<'a> {
23 pub descriptor: &'a ProtocolDescriptor,
24 pub optional: bool,
25}
26
27pub const MODULES_STATE_TYPE: &str = "Modules";
30
31pub const SET_ENABLED: &str = "set_enabled";
33
34pub 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
49pub 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 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 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
239pub 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(¬es),
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 let plain = compose(&shell, &[part(¬es)]).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(¬es), part(&tags)]).unwrap();
432 assert_eq!(composed.events[0].name, "notes.added");
433 tags.commands[0].params = vec![param("note")];
435 assert!(compose(&shell, &[part(¬es), 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(¬es), 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}