1use std::{
2 collections::{BTreeMap, BTreeSet, HashMap},
3 fs,
4 path::{Path, PathBuf},
5};
6
7use synapse_codegen_cfs::CfsPacketKind;
8
9use crate::{
10 Error, ImportGraph, ParsedUnit, collect_input_paths, imported_constants_for_unit,
11 load_import_graphs, units_by_path, validate_cfs_graph,
12};
13
14const MANIFEST_VERSION: u64 = 1;
15const MAX_TOPIC_ID: u64 = u16::MAX as u64;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct MissionManifest {
20 version: u64,
21 command_topics: BTreeMap<String, u16>,
22 telemetry_topics: BTreeMap<String, u16>,
23}
24
25impl MissionManifest {
26 pub fn parse(source: &str) -> Result<Self, Error> {
28 let value = toml::from_str::<toml::Value>(source)
29 .map_err(|error| Error::Manifest(format!("invalid mission manifest TOML: {error}")))?;
30 parse_manifest_value(&value)
31 }
32
33 pub fn load(path: impl AsRef<Path>) -> Result<Self, Error> {
35 let path = path.as_ref();
36 let source = fs::read_to_string(path).map_err(|error| {
37 Error::Manifest(format!(
38 "error reading mission manifest `{}`: {error}",
39 path.display()
40 ))
41 })?;
42 Self::parse(&source).map_err(|error| {
43 Error::Manifest(format!(
44 "error parsing mission manifest `{}`: {error}",
45 path.display()
46 ))
47 })
48 }
49
50 pub fn version(&self) -> u64 {
52 self.version
53 }
54
55 pub fn command_topics(&self) -> &BTreeMap<String, u16> {
57 &self.command_topics
58 }
59
60 pub fn telemetry_topics(&self) -> &BTreeMap<String, u16> {
62 &self.telemetry_topics
63 }
64}
65
66pub fn check_paths_with_manifest<I, P>(inputs: I, manifest: &MissionManifest) -> Result<(), Error>
68where
69 I: IntoIterator<Item = P>,
70 P: AsRef<Path>,
71{
72 prepare_topics(inputs, manifest)?;
73 Ok(())
74}
75
76pub fn generate_routing_header<I, P>(inputs: I, manifest: &MissionManifest) -> Result<String, Error>
78where
79 I: IntoIterator<Item = P>,
80 P: AsRef<Path>,
81{
82 let topics = prepare_topics(inputs, manifest)?;
83 Ok(render_routing_header(&topics, manifest))
84}
85
86pub fn write_routing_header<I, P>(
88 inputs: I,
89 manifest: &MissionManifest,
90 output: impl AsRef<Path>,
91) -> Result<PathBuf, Error>
92where
93 I: IntoIterator<Item = P>,
94 P: AsRef<Path>,
95{
96 let header = generate_routing_header(inputs, manifest)?;
97 let output = output.as_ref();
98 if let Some(parent) = output.parent()
99 && !parent.as_os_str().is_empty()
100 {
101 fs::create_dir_all(parent)?;
102 }
103 fs::write(output, header)?;
104 Ok(output.to_path_buf())
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
108enum TopicKind {
109 Command,
110 Telemetry,
111}
112
113impl TopicKind {
114 fn label(self) -> &'static str {
115 match self {
116 Self::Command => "command",
117 Self::Telemetry => "telemetry",
118 }
119 }
120
121 fn mapping_macro(self) -> &'static str {
122 match self {
123 Self::Command => "CFE_PLATFORM_CMD_TOPICID_TO_MIDV",
124 Self::Telemetry => "CFE_PLATFORM_TLM_TOPICID_TO_MIDV",
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
130struct LogicalTopic {
131 kind: TopicKind,
132 name: String,
133}
134
135fn parse_manifest_value(value: &toml::Value) -> Result<MissionManifest, Error> {
136 let root = value
137 .as_table()
138 .ok_or_else(|| manifest_error("manifest root must be a TOML table"))?;
139 reject_unknown_keys(root, &["version", "topics"], "manifest root")?;
140
141 let version = required_integer(root, "version", "manifest root")?;
142 if version != MANIFEST_VERSION {
143 return Err(manifest_error(format!(
144 "unsupported manifest version `{version}`; expected `{MANIFEST_VERSION}`"
145 )));
146 }
147
148 let topics = required_table(root, "topics", "manifest root")?;
149 reject_unknown_keys(topics, &["command", "telemetry"], "`topics`")?;
150 let command_topics = parse_topic_assignments(topics.get("command"), TopicKind::Command)?;
151 let telemetry_topics = parse_topic_assignments(topics.get("telemetry"), TopicKind::Telemetry)?;
152
153 Ok(MissionManifest {
154 version,
155 command_topics,
156 telemetry_topics,
157 })
158}
159
160fn required_integer(table: &toml::Table, key: &str, context: &str) -> Result<u64, Error> {
161 let value = table
162 .get(key)
163 .ok_or_else(|| manifest_error(format!("missing `{key}` in {context}")))?;
164 let value = value
165 .as_integer()
166 .ok_or_else(|| manifest_error(format!("`{key}` in {context} must be an integer")))?;
167 u64::try_from(value)
168 .map_err(|_| manifest_error(format!("`{key}` in {context} must not be negative")))
169}
170
171fn required_table<'a>(
172 table: &'a toml::Table,
173 key: &str,
174 context: &str,
175) -> Result<&'a toml::Table, Error> {
176 table
177 .get(key)
178 .ok_or_else(|| manifest_error(format!("missing `{key}` table in {context}")))?
179 .as_table()
180 .ok_or_else(|| manifest_error(format!("`{key}` in {context} must be a table")))
181}
182
183fn parse_topic_assignments(
184 value: Option<&toml::Value>,
185 kind: TopicKind,
186) -> Result<BTreeMap<String, u16>, Error> {
187 let Some(value) = value else {
188 return Ok(BTreeMap::new());
189 };
190 let table = value
191 .as_table()
192 .ok_or_else(|| manifest_error(format!("`topics.{}` must be a table", kind.label())))?;
193 let mut assignments = BTreeMap::new();
194 let mut used_ids = HashMap::<u16, String>::new();
195
196 for (topic, value) in table {
197 if topic.trim().is_empty() {
198 return Err(manifest_error(format!(
199 "`topics.{}` contains an empty topic name",
200 kind.label()
201 )));
202 }
203 let integer = value.as_integer().ok_or_else(|| {
204 manifest_error(format!(
205 "topic ID for {} topic `{topic}` must be an integer",
206 kind.label()
207 ))
208 })?;
209 let topic_id = u64::try_from(integer).map_err(|_| {
210 manifest_error(format!(
211 "topic ID for {} topic `{topic}` must not be negative",
212 kind.label()
213 ))
214 })?;
215 if topic_id > MAX_TOPIC_ID {
216 return Err(manifest_error(format!(
217 "topic ID for {} topic `{topic}` exceeds 0xFFFF",
218 kind.label()
219 )));
220 }
221 let topic_id = topic_id as u16;
222 if let Some(first_topic) = used_ids.insert(topic_id, topic.clone()) {
223 return Err(manifest_error(format!(
224 "duplicate {} topic ID `0x{topic_id:04X}` assigned to `{first_topic}` and `{topic}`",
225 kind.label()
226 )));
227 }
228 assignments.insert(topic.clone(), topic_id);
229 }
230
231 Ok(assignments)
232}
233
234fn reject_unknown_keys(table: &toml::Table, allowed: &[&str], context: &str) -> Result<(), Error> {
235 for key in table.keys() {
236 if !allowed.contains(&key.as_str()) {
237 return Err(manifest_error(format!("unknown key `{key}` in {context}")));
238 }
239 }
240 Ok(())
241}
242
243fn manifest_error(message: impl Into<String>) -> Error {
244 Error::Manifest(message.into())
245}
246
247fn prepare_topics<I, P>(inputs: I, manifest: &MissionManifest) -> Result<Vec<LogicalTopic>, Error>
248where
249 I: IntoIterator<Item = P>,
250 P: AsRef<Path>,
251{
252 let inputs = collect_input_paths(inputs, "mission routing")?;
253 let graph = load_import_graphs(&inputs)?;
254 validate_cfs_graph(&graph)?;
255 let topics = collect_logical_topics(&graph)?;
256 validate_topic_symbols(&topics)?;
257 validate_assignments(&topics, manifest)?;
258 Ok(topics)
259}
260
261fn collect_logical_topics(graph: &ImportGraph) -> Result<Vec<LogicalTopic>, Error> {
262 let units = units_by_path(graph);
263 let mut topics = BTreeSet::new();
264
265 for unit in &graph.units {
266 collect_unit_topics(unit, &units, &mut topics)?;
267 }
268
269 if topics.is_empty() {
270 return Err(Error::Mission(
271 "mission routing requires at least one logical command or telemetry topic".to_string(),
272 ));
273 }
274
275 Ok(topics.into_iter().collect())
276}
277
278fn collect_unit_topics(
279 unit: &ParsedUnit,
280 units: &HashMap<PathBuf, &ParsedUnit>,
281 topics: &mut BTreeSet<LogicalTopic>,
282) -> Result<(), Error> {
283 let imported_constants = imported_constants_for_unit(unit, units)?;
284 let packets =
285 synapse_codegen_cfs::collect_cfs_packets_with_constants(&unit.file, &imported_constants)?;
286
287 for packet in packets {
288 let mut qualified = packet.namespace;
289 qualified.push(packet.topic);
290 topics.insert(LogicalTopic {
291 kind: match packet.kind {
292 CfsPacketKind::Command => TopicKind::Command,
293 CfsPacketKind::Telemetry => TopicKind::Telemetry,
294 },
295 name: qualified.join("::"),
296 });
297 }
298 Ok(())
299}
300
301fn validate_assignments(topics: &[LogicalTopic], manifest: &MissionManifest) -> Result<(), Error> {
302 let required_command = topics_for_kind(topics, TopicKind::Command);
303 let required_telemetry = topics_for_kind(topics, TopicKind::Telemetry);
304 let assigned_command = manifest.command_topics.keys().cloned().collect();
305 let assigned_telemetry = manifest.telemetry_topics.keys().cloned().collect();
306
307 let mut problems = Vec::new();
308 collect_wrong_kind(
309 &required_command,
310 &assigned_telemetry,
311 TopicKind::Command,
312 &mut problems,
313 );
314 collect_wrong_kind(
315 &required_telemetry,
316 &assigned_command,
317 TopicKind::Telemetry,
318 &mut problems,
319 );
320 collect_missing(
321 &required_command,
322 &assigned_command,
323 &assigned_telemetry,
324 TopicKind::Command,
325 &mut problems,
326 );
327 collect_missing(
328 &required_telemetry,
329 &assigned_telemetry,
330 &assigned_command,
331 TopicKind::Telemetry,
332 &mut problems,
333 );
334 collect_extra(
335 &assigned_command,
336 &required_command,
337 &required_telemetry,
338 TopicKind::Command,
339 &mut problems,
340 );
341 collect_extra(
342 &assigned_telemetry,
343 &required_telemetry,
344 &required_command,
345 TopicKind::Telemetry,
346 &mut problems,
347 );
348
349 if problems.is_empty() {
350 Ok(())
351 } else {
352 Err(Error::Mission(format!(
353 "mission topic assignments do not match the schemas:\n {}",
354 problems.join("\n ")
355 )))
356 }
357}
358
359fn topics_for_kind(topics: &[LogicalTopic], kind: TopicKind) -> BTreeSet<String> {
360 topics
361 .iter()
362 .filter(|topic| topic.kind == kind)
363 .map(|topic| topic.name.clone())
364 .collect()
365}
366
367fn collect_wrong_kind(
368 required: &BTreeSet<String>,
369 assigned_other: &BTreeSet<String>,
370 actual_kind: TopicKind,
371 problems: &mut Vec<String>,
372) {
373 for topic in required.intersection(assigned_other) {
374 problems.push(format!(
375 "{} topic `{topic}` is assigned under `topics.{}`",
376 actual_kind.label(),
377 opposite_kind(actual_kind).label()
378 ));
379 }
380}
381
382fn collect_missing(
383 required: &BTreeSet<String>,
384 assigned: &BTreeSet<String>,
385 assigned_other: &BTreeSet<String>,
386 kind: TopicKind,
387 problems: &mut Vec<String>,
388) {
389 for topic in required.difference(assigned) {
390 if !assigned_other.contains(topic) {
391 problems.push(format!(
392 "missing {} topic assignment for `{topic}`",
393 kind.label()
394 ));
395 }
396 }
397}
398
399fn collect_extra(
400 assigned: &BTreeSet<String>,
401 required: &BTreeSet<String>,
402 required_other: &BTreeSet<String>,
403 kind: TopicKind,
404 problems: &mut Vec<String>,
405) {
406 for topic in assigned.difference(required) {
407 if !required_other.contains(topic) {
408 problems.push(format!(
409 "unknown {} topic assignment `{topic}`",
410 kind.label()
411 ));
412 }
413 }
414}
415
416fn opposite_kind(kind: TopicKind) -> TopicKind {
417 match kind {
418 TopicKind::Command => TopicKind::Telemetry,
419 TopicKind::Telemetry => TopicKind::Command,
420 }
421}
422
423fn validate_topic_symbols(topics: &[LogicalTopic]) -> Result<(), Error> {
424 let mut symbols = HashMap::<String, &LogicalTopic>::new();
425 for topic in topics {
426 let symbol = topic_symbol(&topic.name);
427 if let Some(first) = symbols.insert(symbol.clone(), topic)
428 && first != topic
429 {
430 return Err(Error::Mission(format!(
431 "{} topic `{}` and {} topic `{}` both generate the C symbol `{symbol}`; rename one logical topic",
432 first.kind.label(),
433 first.name,
434 topic.kind.label(),
435 topic.name
436 )));
437 }
438 }
439 Ok(())
440}
441
442fn render_routing_header(topics: &[LogicalTopic], manifest: &MissionManifest) -> String {
443 let mut out = format!(
444 "/* Generated by Synapse {}. Do not edit directly. */\n\
445 #pragma once\n\
446 #include \"cfe_core_api_msgid_mapping.h\"\n\n",
447 env!("CARGO_PKG_VERSION")
448 );
449
450 for kind in [TopicKind::Command, TopicKind::Telemetry] {
451 let selected = topics
452 .iter()
453 .filter(|topic| topic.kind == kind)
454 .collect::<Vec<_>>();
455 if selected.is_empty() {
456 continue;
457 }
458 out.push_str(&format!("/* {} Topics */\n", title_case(kind.label())));
459 for topic in selected {
460 let topic_id = assignment_for(manifest, topic);
461 let symbol = topic_symbol(&topic.name);
462 out.push_str(&format!(
463 "/* {} */\n\
464 #define {symbol}_TOPICID 0x{topic_id:04X}U\n\
465 #define {symbol}_MID {}({symbol}_TOPICID)\n\n",
466 topic.name,
467 kind.mapping_macro()
468 ));
469 }
470 }
471
472 out
473}
474
475fn assignment_for(manifest: &MissionManifest, topic: &LogicalTopic) -> u16 {
476 let assignments = match topic.kind {
477 TopicKind::Command => &manifest.command_topics,
478 TopicKind::Telemetry => &manifest.telemetry_topics,
479 };
480 *assignments
481 .get(&topic.name)
482 .expect("mission assignments were validated before rendering")
483}
484
485fn topic_symbol(topic: &str) -> String {
486 topic
487 .split("::")
488 .map(to_screaming_snake)
489 .collect::<Vec<_>>()
490 .join("_")
491}
492
493fn to_screaming_snake(name: &str) -> String {
494 let mut out = String::new();
495 for (index, character) in name.chars().enumerate() {
496 if character.is_uppercase() && index > 0 {
497 out.push('_');
498 }
499 out.push(character.to_ascii_uppercase());
500 }
501 out
502}
503
504fn title_case(value: &str) -> String {
505 let mut characters = value.chars();
506 match characters.next() {
507 Some(first) => first.to_ascii_uppercase().to_string() + characters.as_str(),
508 None => String::new(),
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use std::time::{SystemTime, UNIX_EPOCH};
515
516 use super::*;
517
518 const SCHEMA: &str = r#"namespace camera_app
519
520commands CameraCommands {
521 @cc(1)
522 command SetMode {
523 mode: u8
524 }
525
526 @cc(2)
527 command SetExposure {
528 exposure_us: u32
529 }
530}
531
532telemetry CameraStatus {
533 mode: u8
534}
535"#;
536
537 const MANIFEST: &str = r#"version = 1
538
539[topics.command]
540"camera_app::CameraCommands" = 0x82
541
542[topics.telemetry]
543"camera_app::CameraStatus" = 0x82
544"#;
545
546 #[test]
547 fn parses_separate_command_and_telemetry_topic_spaces() {
548 let manifest = MissionManifest::parse(MANIFEST).unwrap();
549
550 assert_eq!(manifest.version(), 1);
551 assert_eq!(
552 manifest.command_topics().get("camera_app::CameraCommands"),
553 Some(&0x82)
554 );
555 assert_eq!(
556 manifest.telemetry_topics().get("camera_app::CameraStatus"),
557 Some(&0x82)
558 );
559 }
560
561 #[test]
562 fn rejects_duplicate_topic_ids_within_one_space() {
563 let error = MissionManifest::parse(
564 r#"version = 1
565[topics.command]
566"camera_app::CameraCommands" = 0x82
567"nav_app::NavCommands" = 0x82
568"#,
569 )
570 .unwrap_err();
571
572 assert!(error.to_string().contains(
573 "duplicate command topic ID `0x0082` assigned to `camera_app::CameraCommands` and `nav_app::NavCommands`"
574 ));
575 }
576
577 #[test]
578 fn rejects_unknown_manifest_keys() {
579 let error = MissionManifest::parse(
580 r#"version = 1
581topic = {}
582"#,
583 )
584 .unwrap_err();
585
586 assert_eq!(error.to_string(), "unknown key `topic` in manifest root");
587 }
588
589 #[test]
590 fn rejects_unsupported_manifest_versions() {
591 let error = MissionManifest::parse(
592 r#"version = 2
593[topics]
594"#,
595 )
596 .unwrap_err();
597
598 assert_eq!(
599 error.to_string(),
600 "unsupported manifest version `2`; expected `1`"
601 );
602 }
603
604 #[test]
605 fn rejects_topic_ids_wider_than_cfe_topic_storage() {
606 let error = MissionManifest::parse(
607 r#"version = 1
608[topics.telemetry]
609"camera_app::CameraStatus" = 0x10000
610"#,
611 )
612 .unwrap_err();
613
614 assert_eq!(
615 error.to_string(),
616 "topic ID for telemetry topic `camera_app::CameraStatus` exceeds 0xFFFF"
617 );
618 }
619
620 #[test]
621 fn generates_namespaced_cfe_routing_macros() {
622 let (dir, schema) = write_schema("generates-routing-header", SCHEMA);
623 let manifest = MissionManifest::parse(MANIFEST).unwrap();
624
625 let header = generate_routing_header([&schema], &manifest).unwrap();
626
627 assert!(header.contains("#include \"cfe_core_api_msgid_mapping.h\""));
628 assert!(header.contains("/* camera_app::CameraCommands */"));
629 assert!(header.contains("#define CAMERA_APP_CAMERA_COMMANDS_TOPICID 0x0082U"));
630 assert!(header.contains(
631 "#define CAMERA_APP_CAMERA_COMMANDS_MID CFE_PLATFORM_CMD_TOPICID_TO_MIDV(CAMERA_APP_CAMERA_COMMANDS_TOPICID)"
632 ));
633 assert!(header.contains("#define CAMERA_APP_CAMERA_STATUS_TOPICID 0x0082U"));
634 assert!(header.contains(
635 "#define CAMERA_APP_CAMERA_STATUS_MID CFE_PLATFORM_TLM_TOPICID_TO_MIDV(CAMERA_APP_CAMERA_STATUS_TOPICID)"
636 ));
637 assert!(!dir.join("mission.toml").exists());
638 }
639
640 #[test]
641 fn reports_missing_and_unknown_assignments() {
642 let (_dir, schema) = write_schema("assignment-mismatch", SCHEMA);
643 let manifest = MissionManifest::parse(
644 r#"version = 1
645[topics.command]
646"camera_app::OldCommands" = 0x70
647"#,
648 )
649 .unwrap();
650
651 let error = check_paths_with_manifest([&schema], &manifest).unwrap_err();
652 let message = error.to_string();
653
654 assert!(
655 message.contains("missing command topic assignment for `camera_app::CameraCommands`")
656 );
657 assert!(
658 message.contains("missing telemetry topic assignment for `camera_app::CameraStatus`")
659 );
660 assert!(message.contains("unknown command topic assignment `camera_app::OldCommands`"));
661 }
662
663 #[test]
664 fn reports_assignment_under_the_wrong_topic_kind() {
665 let (_dir, schema) = write_schema("wrong-topic-kind", SCHEMA);
666 let manifest = MissionManifest::parse(
667 r#"version = 1
668[topics.command]
669"camera_app::CameraStatus" = 0x83
670
671[topics.telemetry]
672"camera_app::CameraCommands" = 0x82
673"#,
674 )
675 .unwrap();
676
677 let error = check_paths_with_manifest([&schema], &manifest).unwrap_err();
678 let message = error.to_string();
679
680 assert!(message.contains(
681 "command topic `camera_app::CameraCommands` is assigned under `topics.telemetry`"
682 ));
683 assert!(message.contains(
684 "telemetry topic `camera_app::CameraStatus` is assigned under `topics.command`"
685 ));
686 }
687
688 #[test]
689 fn rejects_schema_defined_mids() {
690 let schema = r#"namespace camera_app
691
692commands CameraCommands {
693 @cc(1)
694 command SetMode { mode: u8 }
695}
696
697@mid(0x0801)
698telemetry LegacyStatus { mode: u8 }
699"#;
700 let (_dir, schema) = write_schema("legacy-packet", schema);
701 let manifest = MissionManifest::parse(
702 r#"version = 1
703[topics.command]
704"camera_app::CameraCommands" = 0x82
705[topics.telemetry]
706"camera_app::LegacyStatus" = 0x83
707"#,
708 )
709 .unwrap();
710
711 let error = check_paths_with_manifest([&schema], &manifest).unwrap_err();
712 assert!(error.to_string().contains(
713 "`@mid(...)` is not supported on `LegacyStatus`; assign its logical topic in the mission manifest"
714 ));
715 }
716
717 #[test]
718 fn rejects_colliding_generated_c_symbols() {
719 let (dir, first) = write_schema(
720 "symbol-collision",
721 "namespace foo_bar\ntelemetry Baz { value: u8 }",
722 );
723 let second = dir.join("second.syn");
724 fs::write(&second, "namespace foo\ntelemetry BarBaz { value: u8 }").unwrap();
725 let manifest = MissionManifest::parse(
726 r#"version = 1
727[topics.telemetry]
728"foo_bar::Baz" = 0x81
729"foo::BarBaz" = 0x82
730"#,
731 )
732 .unwrap();
733
734 let error = generate_routing_header([&first, &second], &manifest).unwrap_err();
735
736 assert!(error.to_string().contains(
737 "telemetry topic `foo::BarBaz` and telemetry topic `foo_bar::Baz` both generate the C symbol `FOO_BAR_BAZ`"
738 ));
739 }
740
741 fn write_schema(name: &str, source: &str) -> (PathBuf, PathBuf) {
742 let stamp = SystemTime::now()
743 .duration_since(UNIX_EPOCH)
744 .unwrap()
745 .as_nanos();
746 let dir = std::env::temp_dir().join(format!(
747 "synapse-mission-{name}-{}-{stamp}",
748 std::process::id()
749 ));
750 fs::create_dir_all(&dir).unwrap();
751 let schema = dir.join("camera.syn");
752 fs::write(&schema, source).unwrap();
753 (dir, schema)
754 }
755}