Skip to main content

cfs_synapse/
mission.rs

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/// Mission-owned assignments from logical Synapse topics to cFE topic IDs.
18#[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    /// Parse a mission manifest from TOML text.
27    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    /// Read and parse a mission manifest from disk.
34    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    /// Manifest format version.
51    pub fn version(&self) -> u64 {
52        self.version
53    }
54
55    /// Command-topic assignments, keyed by fully qualified logical topic.
56    pub fn command_topics(&self) -> &BTreeMap<String, u16> {
57        &self.command_topics
58    }
59
60    /// Telemetry-topic assignments, keyed by fully qualified logical topic.
61    pub fn telemetry_topics(&self) -> &BTreeMap<String, u16> {
62        &self.telemetry_topics
63    }
64}
65
66/// Validate one or more schema roots against a mission manifest.
67pub 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
76/// Generate a cFE routing header from schema roots and a mission manifest.
77pub 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
86/// Generate and write a cFE routing header.
87pub 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}