Skip to main content

cfs_synapse/
lib.rs

1use std::{
2    collections::{HashMap, HashSet},
3    error::Error as StdError,
4    fmt, fs,
5    path::{Path, PathBuf},
6};
7
8use synapse_parser::ast::{BaseType, FieldDef, Item, SynFile};
9
10/// Target language for Synapse code generation.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Lang {
13    /// NASA cFS C header (`.h`).
14    C,
15    /// Rust `#[repr(C)]` bindings (`.rs`).
16    Rust,
17}
18
19impl Lang {
20    /// File extension used for this generated language.
21    pub fn extension(self) -> &'static str {
22        match self {
23            Lang::C => "h",
24            Lang::Rust => "rs",
25        }
26    }
27}
28
29/// Error type returned by the Synapse library facade.
30#[derive(Debug)]
31pub enum Error {
32    Io(std::io::Error),
33    Parse(Box<pest::error::Error<synapse_parser::synapse::Rule>>),
34    Codegen(synapse_codegen_cfs::CodegenError),
35    Import(String),
36    Mission(String),
37}
38
39impl fmt::Display for Error {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Error::Io(e) => write!(f, "{e}"),
43            Error::Parse(e) => write!(f, "{e}"),
44            Error::Codegen(e) => write!(f, "{e}"),
45            Error::Import(e) => write!(f, "{e}"),
46            Error::Mission(e) => write!(f, "{e}"),
47        }
48    }
49}
50
51impl StdError for Error {
52    fn source(&self) -> Option<&(dyn StdError + 'static)> {
53        match self {
54            Error::Io(e) => Some(e),
55            Error::Parse(e) => Some(e),
56            Error::Codegen(e) => Some(e),
57            Error::Import(_) | Error::Mission(_) => None,
58        }
59    }
60}
61
62impl From<std::io::Error> for Error {
63    fn from(value: std::io::Error) -> Self {
64        Error::Io(value)
65    }
66}
67
68impl From<pest::error::Error<synapse_parser::synapse::Rule>> for Error {
69    fn from(value: pest::error::Error<synapse_parser::synapse::Rule>) -> Self {
70        Error::Parse(Box::new(value))
71    }
72}
73
74impl From<synapse_codegen_cfs::CodegenError> for Error {
75    fn from(value: synapse_codegen_cfs::CodegenError) -> Self {
76        Error::Codegen(value)
77    }
78}
79
80/// Generate code from `.syn` source text.
81pub fn generate_str(source: &str, lang: Lang) -> Result<String, Error> {
82    let file = synapse_parser::ast::parse(source)?;
83    generate_parsed(&file, lang)
84}
85
86/// Check `.syn` source text for parser and cFS codegen support.
87pub fn check_str(source: &str) -> Result<(), Error> {
88    let file = synapse_parser::ast::parse(source)?;
89    synapse_codegen_cfs::validate_cfs(&file)?;
90    Ok(())
91}
92
93/// Check a `.syn` input path, validating its import graph and cFS codegen support.
94pub fn check_path(input: impl AsRef<Path>) -> Result<(), Error> {
95    check_paths([input.as_ref()])
96}
97
98/// Check one or more `.syn` input paths as a mission-visible set.
99///
100/// Each root and its imports are validated normally. When more than one root is
101/// supplied, Synapse also validates mission-wide packet ID uniqueness across the
102/// deduplicated import closure.
103pub fn check_paths<I, P>(inputs: I) -> Result<(), Error>
104where
105    I: IntoIterator<Item = P>,
106    P: AsRef<Path>,
107{
108    let inputs = inputs
109        .into_iter()
110        .map(|input| input.as_ref().to_path_buf())
111        .collect::<Vec<_>>();
112    if inputs.is_empty() {
113        return Err(Error::Mission(
114            "check requires at least one input .syn file".to_string(),
115        ));
116    }
117
118    let graph = load_import_graphs(&inputs)?;
119    validate_import_graph(&graph)?;
120    let units_by_path = units_by_path(&graph);
121
122    for unit in &graph.units {
123        let imported_constants = imported_constants_for_unit(unit, &units_by_path)?;
124        synapse_codegen_cfs::validate_cfs_with_constants(&unit.file, &imported_constants)?;
125    }
126    validate_mission_registry(&graph, &units_by_path)?;
127
128    Ok(())
129}
130
131/// Generate code from a `.syn` input path, validating the import graph rooted at that file.
132pub fn generate_path(input: impl AsRef<Path>, lang: Lang) -> Result<String, Error> {
133    let graph = load_import_graph(input.as_ref())?;
134    validate_import_graph(&graph)?;
135    let units_by_path = units_by_path(&graph);
136    let root = graph
137        .units
138        .last()
139        .expect("import graph always contains the root input");
140    let imported_constants = imported_constants_for_unit(root, &units_by_path)?;
141    generate_parsed_with_constants(&root.file, lang, &imported_constants)
142}
143
144fn generate_parsed(file: &SynFile, lang: Lang) -> Result<String, Error> {
145    generate_parsed_with_constants(file, lang, &synapse_codegen_cfs::ResolvedConstants::new())
146}
147
148fn generate_parsed_with_constants(
149    file: &SynFile,
150    lang: Lang,
151    imported_constants: &synapse_codegen_cfs::ResolvedConstants,
152) -> Result<String, Error> {
153    let output = match lang {
154        Lang::C => synapse_codegen_cfs::try_generate_c_with_constants(file, imported_constants)?,
155        Lang::Rust => synapse_codegen_cfs::try_generate_rust_with_constants(
156            file,
157            &Default::default(),
158            imported_constants,
159        )?,
160    };
161    Ok(output)
162}
163
164/// Generate code from an input file and write it into `out_dir`.
165///
166/// The output file uses the input file stem plus the target language extension,
167/// for example `my_msgs.syn` becomes `my_msgs.h` for [`Lang::C`].
168pub fn generate_file(
169    input: impl AsRef<Path>,
170    out_dir: impl AsRef<Path>,
171    lang: Lang,
172) -> Result<PathBuf, Error> {
173    let input = input.as_ref();
174    let output = generate_path(input, lang)?;
175
176    let out_dir = out_dir.as_ref();
177    fs::create_dir_all(out_dir)?;
178
179    let stem = input.file_stem().ok_or_else(|| {
180        std::io::Error::new(
181            std::io::ErrorKind::InvalidInput,
182            format!("input file has no stem: {}", input.display()),
183        )
184    })?;
185    let out_path = out_dir.join(format!("{}.{}", stem.to_string_lossy(), lang.extension()));
186    fs::write(&out_path, output)?;
187    Ok(out_path)
188}
189
190/// Generate the root file and all transitive imports into `out_dir`.
191///
192/// Files are written in dependency order, so imported files appear before files
193/// that depend on them in the returned path list. Output filenames use each
194/// input file stem plus the target language extension.
195pub fn generate_files(
196    input: impl AsRef<Path>,
197    out_dir: impl AsRef<Path>,
198    lang: Lang,
199) -> Result<Vec<PathBuf>, Error> {
200    let graph = load_import_graph(input.as_ref())?;
201    validate_import_graph(&graph)?;
202    let units_by_path = units_by_path(&graph);
203
204    let out_dir = out_dir.as_ref();
205    fs::create_dir_all(out_dir)?;
206
207    let mut written = Vec::new();
208    for unit in &graph.units {
209        let imported_constants = imported_constants_for_unit(unit, &units_by_path)?;
210        let output = generate_parsed_with_constants(&unit.file, lang, &imported_constants)?;
211        let out_path = output_path_for(&unit.path, out_dir, lang)?;
212        fs::write(&out_path, output)?;
213        written.push(out_path);
214    }
215    Ok(written)
216}
217
218#[derive(Debug)]
219struct ImportGraph {
220    units: Vec<ParsedUnit>,
221}
222
223#[derive(Debug)]
224struct ParsedUnit {
225    path: PathBuf,
226    file: SynFile,
227}
228
229fn load_import_graph(input: &Path) -> Result<ImportGraph, Error> {
230    load_import_graphs(&[input.to_path_buf()])
231}
232
233fn load_import_graphs(inputs: &[PathBuf]) -> Result<ImportGraph, Error> {
234    let mut units = Vec::new();
235    let mut visited = HashSet::new();
236    let mut visiting = HashSet::new();
237    let mut stack = Vec::new();
238    for input in inputs {
239        load_import_unit(input, &mut units, &mut visited, &mut visiting, &mut stack)?;
240    }
241    Ok(ImportGraph { units })
242}
243
244fn load_import_unit(
245    input: &Path,
246    units: &mut Vec<ParsedUnit>,
247    visited: &mut HashSet<PathBuf>,
248    visiting: &mut HashSet<PathBuf>,
249    stack: &mut Vec<PathBuf>,
250) -> Result<(), Error> {
251    let path = canonicalize_import_path(input)?;
252    if visited.contains(&path) {
253        return Ok(());
254    }
255    if visiting.contains(&path) {
256        stack.push(path.clone());
257        let cycle = stack
258            .iter()
259            .map(|p| p.display().to_string())
260            .collect::<Vec<_>>()
261            .join(" -> ");
262        stack.pop();
263        return Err(Error::Import(format!("import cycle detected: {cycle}")));
264    }
265
266    visiting.insert(path.clone());
267    stack.push(path.clone());
268
269    let source = fs::read_to_string(&path)
270        .map_err(|e| Error::Import(format!("error reading import `{}`: {e}", path.display())))?;
271    let file = synapse_parser::ast::parse(&source)
272        .map_err(|e| Error::Import(format!("error parsing import `{}`:\n{e}", path.display())))?;
273
274    let base_dir = path.parent().unwrap_or_else(|| Path::new(""));
275    for item in &file.items {
276        if let Item::Import(import) = item {
277            load_import_unit(
278                &base_dir.join(&import.path),
279                units,
280                visited,
281                visiting,
282                stack,
283            )?;
284        }
285    }
286
287    stack.pop();
288    visiting.remove(&path);
289    visited.insert(path.clone());
290    units.push(ParsedUnit { path, file });
291    Ok(())
292}
293
294fn canonicalize_import_path(path: &Path) -> Result<PathBuf, Error> {
295    fs::canonicalize(path)
296        .map_err(|e| Error::Import(format!("error reading import `{}`: {e}", path.display())))
297}
298
299fn validate_import_graph(graph: &ImportGraph) -> Result<(), Error> {
300    let units_by_path = units_by_path(graph);
301    for unit in &graph.units {
302        validate_import_unit(unit, &units_by_path)?;
303    }
304    Ok(())
305}
306
307fn units_by_path(graph: &ImportGraph) -> HashMap<PathBuf, &ParsedUnit> {
308    graph
309        .units
310        .iter()
311        .map(|unit| (unit.path.clone(), unit))
312        .collect()
313}
314
315fn imported_constants_for_unit(
316    unit: &ParsedUnit,
317    units_by_path: &HashMap<PathBuf, &ParsedUnit>,
318) -> Result<synapse_codegen_cfs::ResolvedConstants, Error> {
319    let base_dir = unit.path.parent().unwrap_or_else(|| Path::new(""));
320    let mut constants = synapse_codegen_cfs::ResolvedConstants::new();
321
322    for item in &unit.file.items {
323        let Item::Import(import) = item else {
324            continue;
325        };
326
327        let import_path = canonicalize_import_path(&base_dir.join(&import.path))?;
328        let imported = units_by_path
329            .get(&import_path)
330            .expect("import graph loader parsed direct imports");
331        constants.extend(exported_constants_for_unit(imported, units_by_path)?);
332    }
333
334    Ok(constants)
335}
336
337fn exported_constants_for_unit(
338    unit: &ParsedUnit,
339    units_by_path: &HashMap<PathBuf, &ParsedUnit>,
340) -> Result<synapse_codegen_cfs::ResolvedConstants, Error> {
341    let imported_constants = imported_constants_for_unit(unit, units_by_path)?;
342    let resolved = synapse_codegen_cfs::resolve_integer_constants(&unit.file, &imported_constants);
343    let local_namespace = namespace(&unit.file);
344    let mut exported = synapse_codegen_cfs::ResolvedConstants::new();
345
346    for item in &unit.file.items {
347        let Item::Const(c) = item else {
348            continue;
349        };
350
351        let key = if local_namespace.is_empty() {
352            vec![c.name.clone()]
353        } else {
354            let mut qualified = local_namespace.clone();
355            qualified.push(c.name.clone());
356            qualified
357        };
358        if let Some(value) = resolved.get(&key) {
359            exported.insert(key, *value);
360        }
361    }
362
363    Ok(exported)
364}
365
366fn validate_import_unit(
367    unit: &ParsedUnit,
368    units_by_path: &HashMap<PathBuf, &ParsedUnit>,
369) -> Result<(), Error> {
370    let base_dir = unit.path.parent().unwrap_or_else(|| Path::new(""));
371    let local_namespace = namespace(&unit.file);
372    let mut symbols = local_symbols(&unit.file, &local_namespace);
373    let mut imported_type_suggestions = HashMap::new();
374
375    for item in &unit.file.items {
376        let Item::Import(import) = item else {
377            continue;
378        };
379
380        let import_path = canonicalize_import_path(&base_dir.join(&import.path))?;
381        let imported = units_by_path
382            .get(&import_path)
383            .expect("import graph loader parsed direct imports");
384        let imported_namespace = namespace(&imported.file);
385        let imported_names = declared_names(&imported.file);
386        for name in &imported_names {
387            if !imported_namespace.is_empty() {
388                let mut qualified = imported_namespace.clone();
389                qualified.push(name.clone());
390                imported_type_suggestions.insert(name.clone(), qualified.join("::"));
391            }
392        }
393        symbols.extend(qualified_symbols(&imported_names, &imported_namespace));
394    }
395
396    validate_type_refs(&unit.file, &symbols, &imported_type_suggestions)
397}
398
399#[derive(Debug, Clone)]
400struct MissionPacket {
401    path: PathBuf,
402    namespace: Vec<String>,
403    name: String,
404    kind: synapse_codegen_cfs::CfsPacketKind,
405    mid: u64,
406    cc: Option<u64>,
407}
408
409fn validate_mission_registry(
410    graph: &ImportGraph,
411    units_by_path: &HashMap<PathBuf, &ParsedUnit>,
412) -> Result<(), Error> {
413    let mut telemetry_mids = HashMap::<u64, MissionPacket>::new();
414    let mut command_codes = HashMap::<(u64, u64), MissionPacket>::new();
415
416    for unit in &graph.units {
417        let imported_constants = imported_constants_for_unit(unit, units_by_path)?;
418        let packets = synapse_codegen_cfs::collect_cfs_packets_with_constants(
419            &unit.file,
420            &imported_constants,
421        )?;
422
423        for packet in packets {
424            let packet = MissionPacket {
425                path: unit.path.clone(),
426                namespace: packet.namespace,
427                name: packet.name,
428                kind: packet.kind,
429                mid: packet.mid,
430                cc: packet.cc,
431            };
432
433            match packet.kind {
434                synapse_codegen_cfs::CfsPacketKind::Telemetry => {
435                    if let Some(first) = telemetry_mids.insert(packet.mid, packet.clone()) {
436                        return Err(Error::Mission(format!(
437                            "duplicate telemetry MID `{}` across mission packets `{}` ({}) and `{}` ({})",
438                            format_mid(packet.mid),
439                            packet_name(&first),
440                            first.path.display(),
441                            packet_name(&packet),
442                            packet.path.display()
443                        )));
444                    }
445                }
446                synapse_codegen_cfs::CfsPacketKind::Command => {
447                    let cc = packet
448                        .cc
449                        .expect("cFS packet collector resolves command codes");
450                    if let Some(first) = command_codes.insert((packet.mid, cc), packet.clone()) {
451                        return Err(Error::Mission(format!(
452                            "duplicate command MID/CC pair `{}`/`{}` across mission packets `{}` ({}) and `{}` ({})",
453                            format_mid(packet.mid),
454                            cc,
455                            packet_name(&first),
456                            first.path.display(),
457                            packet_name(&packet),
458                            packet.path.display()
459                        )));
460                    }
461                }
462            }
463        }
464    }
465
466    Ok(())
467}
468
469fn packet_name(packet: &MissionPacket) -> String {
470    if packet.namespace.is_empty() {
471        packet.name.clone()
472    } else {
473        let mut segments = packet.namespace.clone();
474        segments.push(packet.name.clone());
475        segments.join("::")
476    }
477}
478
479fn format_mid(mid: u64) -> String {
480    format!("0x{mid:04X}")
481}
482
483fn output_path_for(input: &Path, out_dir: &Path, lang: Lang) -> Result<PathBuf, Error> {
484    let stem = input.file_stem().ok_or_else(|| {
485        std::io::Error::new(
486            std::io::ErrorKind::InvalidInput,
487            format!("input file has no stem: {}", input.display()),
488        )
489    })?;
490    Ok(out_dir.join(format!("{}.{}", stem.to_string_lossy(), lang.extension())))
491}
492
493fn namespace(file: &SynFile) -> Vec<String> {
494    file.items
495        .iter()
496        .find_map(|item| match item {
497            Item::Namespace(ns) => Some(ns.name.clone()),
498            _ => None,
499        })
500        .unwrap_or_default()
501}
502
503fn local_symbols(file: &SynFile, namespace: &[String]) -> HashSet<Vec<String>> {
504    let mut symbols = HashSet::new();
505    for name in declared_names(file) {
506        symbols.insert(vec![name.clone()]);
507        if !namespace.is_empty() {
508            let mut qualified = namespace.to_vec();
509            qualified.push(name);
510            symbols.insert(qualified);
511        }
512    }
513    symbols
514}
515
516fn qualified_symbols(names: &[String], namespace: &[String]) -> HashSet<Vec<String>> {
517    let mut symbols = HashSet::new();
518    if namespace.is_empty() {
519        return symbols;
520    }
521    for name in names {
522        let mut qualified = namespace.to_vec();
523        qualified.push(name.clone());
524        symbols.insert(qualified);
525    }
526    symbols
527}
528
529fn declared_names(file: &SynFile) -> Vec<String> {
530    file.items
531        .iter()
532        .filter_map(|item| match item {
533            Item::Const(c) => Some(c.name.clone()),
534            Item::Enum(e) => Some(e.name.clone()),
535            Item::Struct(s) | Item::Table(s) => Some(s.name.clone()),
536            Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => Some(m.name.clone()),
537            Item::Namespace(_) | Item::Import(_) => None,
538        })
539        .collect()
540}
541
542fn validate_type_refs(
543    file: &SynFile,
544    symbols: &HashSet<Vec<String>>,
545    imported_type_suggestions: &HashMap<String, String>,
546) -> Result<(), Error> {
547    for item in &file.items {
548        match item {
549            Item::Const(c) => {
550                validate_type_ref(&c.name, &c.ty.base, symbols, imported_type_suggestions)?
551            }
552            Item::Struct(s) | Item::Table(s) => {
553                validate_field_refs(&s.name, &s.fields, symbols, imported_type_suggestions)?
554            }
555            Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => {
556                validate_field_refs(&m.name, &m.fields, symbols, imported_type_suggestions)?
557            }
558            Item::Namespace(_) | Item::Import(_) | Item::Enum(_) => {}
559        }
560    }
561    Ok(())
562}
563
564fn validate_field_refs(
565    container: &str,
566    fields: &[FieldDef],
567    symbols: &HashSet<Vec<String>>,
568    imported_type_suggestions: &HashMap<String, String>,
569) -> Result<(), Error> {
570    for field in fields {
571        validate_type_ref(
572            &format!("{container}.{}", field.name),
573            &field.ty.base,
574            symbols,
575            imported_type_suggestions,
576        )?;
577    }
578    Ok(())
579}
580
581fn validate_type_ref(
582    owner: &str,
583    base: &BaseType,
584    symbols: &HashSet<Vec<String>>,
585    imported_type_suggestions: &HashMap<String, String>,
586) -> Result<(), Error> {
587    let BaseType::Ref(segments) = base else {
588        return Ok(());
589    };
590    if symbols.contains(segments) {
591        return Ok(());
592    }
593    if segments.len() == 1 {
594        if let Some(suggestion) = imported_type_suggestions.get(&segments[0]) {
595            return Err(Error::Import(format!(
596                "imported type reference `{}` in `{owner}` must be namespace-qualified as `{suggestion}`",
597                segments[0]
598            )));
599        }
600    }
601    Err(Error::Import(format!(
602        "unresolved type reference `{}` in `{owner}`",
603        segments.join("::")
604    )))
605}
606
607/// Generate a cFS C header from an input file.
608pub fn generate_c_file(
609    input: impl AsRef<Path>,
610    out_dir: impl AsRef<Path>,
611) -> Result<PathBuf, Error> {
612    generate_file(input, out_dir, Lang::C)
613}
614
615/// Generate Rust `#[repr(C)]` bindings from an input file.
616pub fn generate_rust_file(
617    input: impl AsRef<Path>,
618    out_dir: impl AsRef<Path>,
619) -> Result<PathBuf, Error> {
620    generate_file(input, out_dir, Lang::Rust)
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use std::time::{SystemTime, UNIX_EPOCH};
627
628    #[test]
629    fn generate_c_from_string() {
630        let out = generate_str(
631            "@mid(0x1880)\n@cc(1)\ncommand SetMode { mode: u8 }",
632            Lang::C,
633        )
634        .unwrap();
635        assert!(out.contains("#define SET_MODE_MID  0x1880U"));
636        assert!(out.contains("#define SET_MODE_CC   1U"));
637        assert!(out.contains("CFE_MSG_CommandHeader_t Header;"));
638    }
639
640    #[test]
641    fn generate_rust_from_string() {
642        let out = generate_str("@mid(0x0801)\ntelemetry NavState { x: f64 }", Lang::Rust).unwrap();
643        assert!(out.contains("pub const NAV_STATE_MID: u16 = 0x0801;"));
644        assert!(out.contains("pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
645    }
646
647    #[test]
648    fn rejects_optional_fields() {
649        let err = generate_str(
650            "@mid(0x0801)\ntelemetry Status { error_code?: u32 }",
651            Lang::C,
652        )
653        .unwrap_err();
654        assert_eq!(
655            err.to_string(),
656            "optional field `Status.error_code` is not supported by cFS codegen yet"
657        );
658    }
659
660    #[test]
661    fn check_str_validates_cfs_codegen_support() {
662        let err = check_str("@mid(0x0801)\ntelemetry Status { error_code?: u32 }").unwrap_err();
663        assert_eq!(
664            err.to_string(),
665            "optional field `Status.error_code` is not supported by cFS codegen yet"
666        );
667    }
668
669    #[test]
670    fn rejects_default_values() {
671        let err = generate_str("table Config { exposure_us: u32 = 10000 }", Lang::C).unwrap_err();
672        assert_eq!(
673            err.to_string(),
674            "default value for field `Config.exposure_us` is not supported by cFS codegen yet"
675        );
676    }
677
678    #[test]
679    fn rejects_enum_fields() {
680        let err = generate_str(
681            "enum CameraMode { Idle = 0 Streaming = 1 }\n@mid(0x0801)\ntelemetry Status { mode: CameraMode }",
682            Lang::C,
683        )
684        .unwrap_err();
685        assert_eq!(
686            err.to_string(),
687            "enum field `Status.mode` with type `CameraMode` needs an explicit integer representation for cFS codegen"
688        );
689    }
690
691    #[test]
692    fn rejects_unbounded_strings() {
693        let err = generate_str("struct Label { name: string }", Lang::C).unwrap_err();
694        assert_eq!(
695            err.to_string(),
696            "unbounded string field `Label.name` is not supported by cFS codegen; use `string[<=N]` or `string[N]`"
697        );
698    }
699
700    #[test]
701    fn rejects_legacy_message() {
702        let err = generate_str("@mid(0x0801)\nmessage Status { x: f32 }", Lang::C).unwrap_err();
703        assert_eq!(
704            err.to_string(),
705            "legacy message `Status` is not supported by cFS codegen; use `command` or `telemetry`"
706        );
707    }
708
709    #[test]
710    fn rejects_packet_without_mid() {
711        let err = generate_str("command SetMode { mode: u8 }", Lang::C).unwrap_err();
712        assert_eq!(
713            err.to_string(),
714            "packet `SetMode` is missing required `@mid(...)`"
715        );
716    }
717
718    #[test]
719    fn rejects_command_without_cc() {
720        let err = generate_str("@mid(0x1880)\ncommand SetMode { mode: u8 }", Lang::C).unwrap_err();
721        assert_eq!(
722            err.to_string(),
723            "command `SetMode` is missing required `@cc(...)`"
724        );
725    }
726
727    #[test]
728    fn rejects_mid_range_mismatch() {
729        let err = generate_str(
730            "@mid(0x0801)\n@cc(1)\ncommand SetMode { mode: u8 }",
731            Lang::C,
732        )
733        .unwrap_err();
734        assert_eq!(
735            err.to_string(),
736            "packet `SetMode` has MID `0x0801U`, expected command MID with bit 0x1000 set"
737        );
738    }
739
740    #[test]
741    fn rejects_dynamic_arrays() {
742        let err =
743            generate_str("@mid(0x0801)\ntelemetry Samples { values: f32[] }", Lang::C).unwrap_err();
744        assert_eq!(
745            err.to_string(),
746            "dynamic array field `Samples.values` with type `f32[]` is not supported by cFS codegen yet"
747        );
748    }
749
750    #[test]
751    fn rejects_non_string_bounded_arrays() {
752        let err = generate_str("table Buffer { bytes: u8[<=256] }", Lang::C).unwrap_err();
753        assert_eq!(
754            err.to_string(),
755            "bounded array field `Buffer.bytes` with type `u8[<=256]` is not supported by cFS codegen yet"
756        );
757    }
758
759    #[test]
760    fn generate_path_validates_imported_type_refs() {
761        let dir = test_dir("validates-imported-type-refs");
762        fs::write(
763            dir.join("std_msgs.syn"),
764            "namespace std_msgs\nstruct Header { seq: u32 }",
765        )
766        .unwrap();
767        let input = dir.join("camera.syn");
768        fs::write(
769            &input,
770            r#"namespace camera_app
771import "std_msgs.syn"
772@mid(0x0881)
773telemetry CameraStatus {
774    header: std_msgs::Header
775}
776"#,
777        )
778        .unwrap();
779
780        let out = generate_path(&input, Lang::C).unwrap();
781        assert!(out.contains("#include \"std_msgs.h\""));
782        assert!(out.contains("std_msgs_Header_t header;"));
783    }
784
785    #[test]
786    fn check_path_validates_imported_codegen_support() {
787        let dir = test_dir("check-validates-imported-codegen-support");
788        fs::write(
789            dir.join("bad.syn"),
790            "namespace bad\nstruct Unsupported { count?: u32 }",
791        )
792        .unwrap();
793        let input = dir.join("root.syn");
794        fs::write(
795            &input,
796            r#"namespace root
797import "bad.syn"
798struct Root { unsupported: bad::Unsupported }
799"#,
800        )
801        .unwrap();
802
803        let err = check_path(&input).unwrap_err();
804        assert_eq!(
805            err.to_string(),
806            "optional field `Unsupported.count` is not supported by cFS codegen yet"
807        );
808    }
809
810    #[test]
811    fn check_paths_rejects_duplicate_telemetry_mids_across_roots() {
812        let dir = test_dir("check-paths-duplicate-telemetry-mids");
813        let nav = dir.join("nav.syn");
814        fs::write(
815            &nav,
816            "namespace nav_app\n@mid(0x0801)\ntelemetry NavState { x: f64 }",
817        )
818        .unwrap();
819        let payload = dir.join("payload.syn");
820        fs::write(
821            &payload,
822            "namespace payload_app\n@mid(0x0801)\ntelemetry PayloadStatus { temp: f32 }",
823        )
824        .unwrap();
825
826        let err = check_paths([&nav, &payload]).unwrap_err();
827        let msg = err.to_string();
828        assert!(msg.contains("duplicate telemetry MID `0x0801`"));
829        assert!(msg.contains("nav_app::NavState"));
830        assert!(msg.contains("payload_app::PayloadStatus"));
831    }
832
833    #[test]
834    fn check_paths_rejects_duplicate_command_mid_cc_pairs_across_roots() {
835        let dir = test_dir("check-paths-duplicate-command-codes");
836        let camera = dir.join("camera.syn");
837        fs::write(
838            &camera,
839            "namespace camera_app\n@mid(0x1880)\n@cc(1)\ncommand SetMode { mode: u8 }",
840        )
841        .unwrap();
842        let radio = dir.join("radio.syn");
843        fs::write(
844            &radio,
845            "namespace radio_app\n@mid(0x1880)\n@cc(1)\ncommand SetMode { mode: u8 }",
846        )
847        .unwrap();
848
849        let err = check_paths([&camera, &radio]).unwrap_err();
850        let msg = err.to_string();
851        assert!(msg.contains("duplicate command MID/CC pair `0x1880`/`1`"));
852        assert!(msg.contains("camera_app::SetMode"));
853        assert!(msg.contains("radio_app::SetMode"));
854    }
855
856    #[test]
857    fn check_paths_allows_shared_command_mid_with_distinct_command_codes() {
858        let dir = test_dir("check-paths-shared-command-mid");
859        let camera = dir.join("camera.syn");
860        fs::write(
861            &camera,
862            "namespace camera_app\n@mid(0x1880)\n@cc(1)\ncommand SetMode { mode: u8 }",
863        )
864        .unwrap();
865        let radio = dir.join("radio.syn");
866        fs::write(
867            &radio,
868            "namespace radio_app\n@mid(0x1880)\n@cc(2)\ncommand SetMode { mode: u8 }",
869        )
870        .unwrap();
871
872        check_paths([&camera, &radio]).unwrap();
873    }
874
875    #[test]
876    fn generate_path_resolves_imported_constants_in_attrs() {
877        let dir = test_dir("resolves-imported-constants-in-attrs");
878        fs::write(
879            dir.join("nav_ids.syn"),
880            "namespace nav_ids\nconst NAV_STATE_MID: u16 = 0x0801",
881        )
882        .unwrap();
883        let input = dir.join("nav.syn");
884        fs::write(
885            &input,
886            r#"namespace nav_app
887import "nav_ids.syn"
888@mid(nav_ids::NAV_STATE_MID)
889telemetry NavState {
890    x: f64
891}
892"#,
893        )
894        .unwrap();
895
896        let out = generate_path(&input, Lang::C).unwrap();
897        assert!(out.contains("#define NAV_STATE_MID  0x0801U"));
898    }
899
900    #[test]
901    fn generate_path_resolves_imported_alias_constants_in_attrs() {
902        let dir = test_dir("resolves-imported-alias-constants-in-attrs");
903        fs::write(
904            dir.join("mission_ids.syn"),
905            "namespace mission_ids\nconst NAV_CMD_MID: u16 = 0x1880",
906        )
907        .unwrap();
908        fs::write(
909            dir.join("nav_ids.syn"),
910            r#"namespace nav_ids
911import "mission_ids.syn"
912const SET_MODE_MID: u16 = mission_ids::NAV_CMD_MID
913const SET_MODE_CC: u16 = 2
914"#,
915        )
916        .unwrap();
917        let input = dir.join("nav.syn");
918        fs::write(
919            &input,
920            r#"namespace nav_app
921import "nav_ids.syn"
922@mid(nav_ids::SET_MODE_MID)
923@cc(nav_ids::SET_MODE_CC)
924command SetMode {
925    mode: u8
926}
927"#,
928        )
929        .unwrap();
930
931        let out = generate_path(&input, Lang::Rust).unwrap();
932        assert!(out.contains("pub const SET_MODE_MID: u16 = 0x1880;"));
933        assert!(out.contains("pub const SET_MODE_CC: u16 = 2;"));
934    }
935
936    #[test]
937    fn generate_path_rejects_transitive_only_constant_refs() {
938        let dir = test_dir("rejects-transitive-only-constant-refs");
939        fs::write(
940            dir.join("mission_ids.syn"),
941            "namespace mission_ids\nconst NAV_STATE_MID: u16 = 0x0801",
942        )
943        .unwrap();
944        fs::write(
945            dir.join("nav_ids.syn"),
946            r#"namespace nav_ids
947import "mission_ids.syn"
948const LOCAL_MID: u16 = mission_ids::NAV_STATE_MID
949"#,
950        )
951        .unwrap();
952        let input = dir.join("nav.syn");
953        fs::write(
954            &input,
955            r#"namespace nav_app
956import "nav_ids.syn"
957@mid(mission_ids::NAV_STATE_MID)
958telemetry NavState {
959    x: f64
960}
961"#,
962        )
963        .unwrap();
964
965        let err = generate_path(&input, Lang::C).unwrap_err();
966        assert_eq!(
967            err.to_string(),
968            "packet `NavState` has unresolved or non-integer `@mid(...)`; cFS codegen requires an integer, hex, local integer constant, or imported integer constant message ID"
969        );
970    }
971
972    #[test]
973    fn generate_path_rejects_unqualified_imported_type_refs() {
974        let dir = test_dir("rejects-unqualified-imported-type-refs");
975        fs::write(
976            dir.join("std_msgs.syn"),
977            "namespace std_msgs\nstruct Header { seq: u32 }",
978        )
979        .unwrap();
980        let input = dir.join("camera.syn");
981        fs::write(
982            &input,
983            r#"namespace camera_app
984import "std_msgs.syn"
985@mid(0x0881)
986telemetry CameraStatus {
987    header: Header
988}
989"#,
990        )
991        .unwrap();
992
993        let err = generate_path(&input, Lang::C).unwrap_err();
994        assert_eq!(
995            err.to_string(),
996            "imported type reference `Header` in `CameraStatus.header` must be namespace-qualified as `std_msgs::Header`"
997        );
998    }
999
1000    #[test]
1001    fn generate_path_rejects_missing_import_file() {
1002        let dir = test_dir("missing-import");
1003        let input = dir.join("camera.syn");
1004        fs::write(&input, r#"import "missing.syn""#).unwrap();
1005
1006        let err = generate_path(&input, Lang::C).unwrap_err();
1007        assert!(err.to_string().contains("error reading import"));
1008        assert!(err.to_string().contains("missing.syn"));
1009    }
1010
1011    #[test]
1012    fn generate_path_rejects_unresolved_type_ref() {
1013        let dir = test_dir("unresolved-type-ref");
1014        let input = dir.join("camera.syn");
1015        fs::write(
1016            &input,
1017            "@mid(0x0881)\ntelemetry CameraStatus { header: std_msgs::Header }",
1018        )
1019        .unwrap();
1020
1021        let err = generate_path(&input, Lang::C).unwrap_err();
1022        assert_eq!(
1023            err.to_string(),
1024            "unresolved type reference `std_msgs::Header` in `CameraStatus.header`"
1025        );
1026    }
1027
1028    #[test]
1029    fn generate_path_validates_transitive_imports() {
1030        let dir = test_dir("validates-transitive-imports");
1031        fs::write(
1032            dir.join("time.syn"),
1033            "namespace time\nstruct Time { sec: u32 }",
1034        )
1035        .unwrap();
1036        fs::write(
1037            dir.join("std_msgs.syn"),
1038            r#"namespace std_msgs
1039import "time.syn"
1040struct Header { stamp: time::Time }
1041"#,
1042        )
1043        .unwrap();
1044        let input = dir.join("camera.syn");
1045        fs::write(
1046            &input,
1047            r#"namespace camera_app
1048import "std_msgs.syn"
1049@mid(0x0881)
1050telemetry CameraStatus {
1051    header: std_msgs::Header
1052}
1053"#,
1054        )
1055        .unwrap();
1056
1057        let out = generate_path(&input, Lang::C).unwrap();
1058        assert!(out.contains("#include \"std_msgs.h\""));
1059        assert!(out.contains("std_msgs_Header_t header;"));
1060    }
1061
1062    #[test]
1063    fn generate_path_rejects_missing_transitive_import_file() {
1064        let dir = test_dir("missing-transitive-import");
1065        fs::write(
1066            dir.join("std_msgs.syn"),
1067            r#"namespace std_msgs
1068import "missing_time.syn"
1069struct Header { seq: u32 }
1070"#,
1071        )
1072        .unwrap();
1073        let input = dir.join("camera.syn");
1074        fs::write(
1075            &input,
1076            r#"namespace camera_app
1077import "std_msgs.syn"
1078@mid(0x0881)
1079telemetry CameraStatus {
1080    header: std_msgs::Header
1081}
1082"#,
1083        )
1084        .unwrap();
1085
1086        let err = generate_path(&input, Lang::C).unwrap_err();
1087        assert!(err.to_string().contains("error reading import"));
1088        assert!(err.to_string().contains("missing_time.syn"));
1089    }
1090
1091    #[test]
1092    fn generate_path_rejects_references_to_transitive_only_imports() {
1093        let dir = test_dir("rejects-transitive-only-import-reference");
1094        fs::write(
1095            dir.join("time.syn"),
1096            "namespace time\nstruct Time { sec: u32 }",
1097        )
1098        .unwrap();
1099        fs::write(
1100            dir.join("std_msgs.syn"),
1101            r#"namespace std_msgs
1102import "time.syn"
1103struct Header { stamp: time::Time }
1104"#,
1105        )
1106        .unwrap();
1107        let input = dir.join("camera.syn");
1108        fs::write(
1109            &input,
1110            r#"namespace camera_app
1111import "std_msgs.syn"
1112@mid(0x0881)
1113telemetry CameraStatus {
1114    stamp: time::Time
1115}
1116"#,
1117        )
1118        .unwrap();
1119
1120        let err = generate_path(&input, Lang::C).unwrap_err();
1121        assert_eq!(
1122            err.to_string(),
1123            "unresolved type reference `time::Time` in `CameraStatus.stamp`"
1124        );
1125    }
1126
1127    #[test]
1128    fn generate_files_writes_import_closure_in_dependency_order() {
1129        let dir = test_dir("generate-files-import-closure");
1130        fs::write(
1131            dir.join("time.syn"),
1132            "namespace time\nstruct Time { sec: u32 }",
1133        )
1134        .unwrap();
1135        fs::write(
1136            dir.join("std_msgs.syn"),
1137            r#"namespace std_msgs
1138import "time.syn"
1139struct Header { stamp: time::Time }
1140"#,
1141        )
1142        .unwrap();
1143        let input = dir.join("camera.syn");
1144        fs::write(
1145            &input,
1146            r#"namespace camera_app
1147import "std_msgs.syn"
1148@mid(0x0881)
1149telemetry CameraStatus {
1150    header: std_msgs::Header
1151}
1152"#,
1153        )
1154        .unwrap();
1155
1156        let out_dir = dir.join("generated");
1157        let written = generate_files(&input, &out_dir, Lang::C).unwrap();
1158        let names = written
1159            .iter()
1160            .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
1161            .collect::<Vec<_>>();
1162        assert_eq!(names, ["time.h", "std_msgs.h", "camera.h"]);
1163        assert!(
1164            fs::read_to_string(out_dir.join("std_msgs.h"))
1165                .unwrap()
1166                .contains("#include \"time.h\"")
1167        );
1168        assert!(
1169            fs::read_to_string(out_dir.join("camera.h"))
1170                .unwrap()
1171                .contains("#include \"std_msgs.h\"")
1172        );
1173    }
1174
1175    #[test]
1176    fn generate_path_rejects_import_cycles() {
1177        let dir = test_dir("rejects-import-cycles");
1178        fs::write(
1179            dir.join("a.syn"),
1180            r#"namespace a
1181import "b.syn"
1182struct A { b: b::B }
1183"#,
1184        )
1185        .unwrap();
1186        fs::write(
1187            dir.join("b.syn"),
1188            r#"namespace b
1189import "a.syn"
1190struct B { a: a::A }
1191"#,
1192        )
1193        .unwrap();
1194
1195        let err = generate_path(dir.join("a.syn"), Lang::C).unwrap_err();
1196        assert!(err.to_string().contains("import cycle detected"));
1197        assert!(err.to_string().contains("a.syn"));
1198        assert!(err.to_string().contains("b.syn"));
1199    }
1200
1201    fn test_dir(name: &str) -> PathBuf {
1202        let stamp = SystemTime::now()
1203            .duration_since(UNIX_EPOCH)
1204            .unwrap()
1205            .as_nanos();
1206        let dir =
1207            std::env::temp_dir().join(format!("synapse-{name}-{}-{stamp}", std::process::id()));
1208        fs::create_dir_all(&dir).unwrap();
1209        dir
1210    }
1211}