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}
37
38impl fmt::Display for Error {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            Error::Io(e) => write!(f, "{e}"),
42            Error::Parse(e) => write!(f, "{e}"),
43            Error::Codegen(e) => write!(f, "{e}"),
44            Error::Import(e) => write!(f, "{e}"),
45        }
46    }
47}
48
49impl StdError for Error {
50    fn source(&self) -> Option<&(dyn StdError + 'static)> {
51        match self {
52            Error::Io(e) => Some(e),
53            Error::Parse(e) => Some(e),
54            Error::Codegen(e) => Some(e),
55            Error::Import(_) => None,
56        }
57    }
58}
59
60impl From<std::io::Error> for Error {
61    fn from(value: std::io::Error) -> Self {
62        Error::Io(value)
63    }
64}
65
66impl From<pest::error::Error<synapse_parser::synapse::Rule>> for Error {
67    fn from(value: pest::error::Error<synapse_parser::synapse::Rule>) -> Self {
68        Error::Parse(Box::new(value))
69    }
70}
71
72impl From<synapse_codegen_cfs::CodegenError> for Error {
73    fn from(value: synapse_codegen_cfs::CodegenError) -> Self {
74        Error::Codegen(value)
75    }
76}
77
78/// Generate code from `.syn` source text.
79pub fn generate_str(source: &str, lang: Lang) -> Result<String, Error> {
80    let file = synapse_parser::ast::parse(source)?;
81    generate_parsed(&file, lang)
82}
83
84/// Generate code from a `.syn` input path, validating the import graph rooted at that file.
85pub fn generate_path(input: impl AsRef<Path>, lang: Lang) -> Result<String, Error> {
86    let graph = load_import_graph(input.as_ref())?;
87    validate_import_graph(&graph)?;
88    let units_by_path = units_by_path(&graph);
89    let root = graph
90        .units
91        .last()
92        .expect("import graph always contains the root input");
93    let imported_constants = imported_constants_for_unit(root, &units_by_path)?;
94    generate_parsed_with_constants(&root.file, lang, &imported_constants)
95}
96
97fn generate_parsed(file: &SynFile, lang: Lang) -> Result<String, Error> {
98    generate_parsed_with_constants(file, lang, &synapse_codegen_cfs::ResolvedConstants::new())
99}
100
101fn generate_parsed_with_constants(
102    file: &SynFile,
103    lang: Lang,
104    imported_constants: &synapse_codegen_cfs::ResolvedConstants,
105) -> Result<String, Error> {
106    let output = match lang {
107        Lang::C => synapse_codegen_cfs::try_generate_c_with_constants(file, imported_constants)?,
108        Lang::Rust => synapse_codegen_cfs::try_generate_rust_with_constants(
109            file,
110            &Default::default(),
111            imported_constants,
112        )?,
113    };
114    Ok(output)
115}
116
117/// Generate code from an input file and write it into `out_dir`.
118///
119/// The output file uses the input file stem plus the target language extension,
120/// for example `my_msgs.syn` becomes `my_msgs.h` for [`Lang::C`].
121pub fn generate_file(
122    input: impl AsRef<Path>,
123    out_dir: impl AsRef<Path>,
124    lang: Lang,
125) -> Result<PathBuf, Error> {
126    let input = input.as_ref();
127    let output = generate_path(input, lang)?;
128
129    let out_dir = out_dir.as_ref();
130    fs::create_dir_all(out_dir)?;
131
132    let stem = input.file_stem().ok_or_else(|| {
133        std::io::Error::new(
134            std::io::ErrorKind::InvalidInput,
135            format!("input file has no stem: {}", input.display()),
136        )
137    })?;
138    let out_path = out_dir.join(format!("{}.{}", stem.to_string_lossy(), lang.extension()));
139    fs::write(&out_path, output)?;
140    Ok(out_path)
141}
142
143/// Generate the root file and all transitive imports into `out_dir`.
144///
145/// Files are written in dependency order, so imported files appear before files
146/// that depend on them in the returned path list. Output filenames use each
147/// input file stem plus the target language extension.
148pub fn generate_files(
149    input: impl AsRef<Path>,
150    out_dir: impl AsRef<Path>,
151    lang: Lang,
152) -> Result<Vec<PathBuf>, Error> {
153    let graph = load_import_graph(input.as_ref())?;
154    validate_import_graph(&graph)?;
155    let units_by_path = units_by_path(&graph);
156
157    let out_dir = out_dir.as_ref();
158    fs::create_dir_all(out_dir)?;
159
160    let mut written = Vec::new();
161    for unit in &graph.units {
162        let imported_constants = imported_constants_for_unit(unit, &units_by_path)?;
163        let output = generate_parsed_with_constants(&unit.file, lang, &imported_constants)?;
164        let out_path = output_path_for(&unit.path, out_dir, lang)?;
165        fs::write(&out_path, output)?;
166        written.push(out_path);
167    }
168    Ok(written)
169}
170
171#[derive(Debug)]
172struct ImportGraph {
173    units: Vec<ParsedUnit>,
174}
175
176#[derive(Debug)]
177struct ParsedUnit {
178    path: PathBuf,
179    file: SynFile,
180}
181
182fn load_import_graph(input: &Path) -> Result<ImportGraph, Error> {
183    let mut units = Vec::new();
184    let mut visited = HashSet::new();
185    let mut visiting = HashSet::new();
186    let mut stack = Vec::new();
187    load_import_unit(input, &mut units, &mut visited, &mut visiting, &mut stack)?;
188    Ok(ImportGraph { units })
189}
190
191fn load_import_unit(
192    input: &Path,
193    units: &mut Vec<ParsedUnit>,
194    visited: &mut HashSet<PathBuf>,
195    visiting: &mut HashSet<PathBuf>,
196    stack: &mut Vec<PathBuf>,
197) -> Result<(), Error> {
198    let path = canonicalize_import_path(input)?;
199    if visited.contains(&path) {
200        return Ok(());
201    }
202    if visiting.contains(&path) {
203        stack.push(path.clone());
204        let cycle = stack
205            .iter()
206            .map(|p| p.display().to_string())
207            .collect::<Vec<_>>()
208            .join(" -> ");
209        stack.pop();
210        return Err(Error::Import(format!("import cycle detected: {cycle}")));
211    }
212
213    visiting.insert(path.clone());
214    stack.push(path.clone());
215
216    let source = fs::read_to_string(&path)
217        .map_err(|e| Error::Import(format!("error reading import `{}`: {e}", path.display())))?;
218    let file = synapse_parser::ast::parse(&source)
219        .map_err(|e| Error::Import(format!("error parsing import `{}`:\n{e}", path.display())))?;
220
221    let base_dir = path.parent().unwrap_or_else(|| Path::new(""));
222    for item in &file.items {
223        if let Item::Import(import) = item {
224            load_import_unit(
225                &base_dir.join(&import.path),
226                units,
227                visited,
228                visiting,
229                stack,
230            )?;
231        }
232    }
233
234    stack.pop();
235    visiting.remove(&path);
236    visited.insert(path.clone());
237    units.push(ParsedUnit { path, file });
238    Ok(())
239}
240
241fn canonicalize_import_path(path: &Path) -> Result<PathBuf, Error> {
242    fs::canonicalize(path)
243        .map_err(|e| Error::Import(format!("error reading import `{}`: {e}", path.display())))
244}
245
246fn validate_import_graph(graph: &ImportGraph) -> Result<(), Error> {
247    let units_by_path = units_by_path(graph);
248    for unit in &graph.units {
249        validate_import_unit(unit, &units_by_path)?;
250    }
251    Ok(())
252}
253
254fn units_by_path(graph: &ImportGraph) -> HashMap<PathBuf, &ParsedUnit> {
255    graph
256        .units
257        .iter()
258        .map(|unit| (unit.path.clone(), unit))
259        .collect()
260}
261
262fn imported_constants_for_unit(
263    unit: &ParsedUnit,
264    units_by_path: &HashMap<PathBuf, &ParsedUnit>,
265) -> Result<synapse_codegen_cfs::ResolvedConstants, Error> {
266    let base_dir = unit.path.parent().unwrap_or_else(|| Path::new(""));
267    let mut constants = synapse_codegen_cfs::ResolvedConstants::new();
268
269    for item in &unit.file.items {
270        let Item::Import(import) = item else {
271            continue;
272        };
273
274        let import_path = canonicalize_import_path(&base_dir.join(&import.path))?;
275        let imported = units_by_path
276            .get(&import_path)
277            .expect("import graph loader parsed direct imports");
278        constants.extend(exported_constants_for_unit(imported, units_by_path)?);
279    }
280
281    Ok(constants)
282}
283
284fn exported_constants_for_unit(
285    unit: &ParsedUnit,
286    units_by_path: &HashMap<PathBuf, &ParsedUnit>,
287) -> Result<synapse_codegen_cfs::ResolvedConstants, Error> {
288    let imported_constants = imported_constants_for_unit(unit, units_by_path)?;
289    let resolved = synapse_codegen_cfs::resolve_integer_constants(&unit.file, &imported_constants);
290    let local_namespace = namespace(&unit.file);
291    let mut exported = synapse_codegen_cfs::ResolvedConstants::new();
292
293    for item in &unit.file.items {
294        let Item::Const(c) = item else {
295            continue;
296        };
297
298        let key = if local_namespace.is_empty() {
299            vec![c.name.clone()]
300        } else {
301            let mut qualified = local_namespace.clone();
302            qualified.push(c.name.clone());
303            qualified
304        };
305        if let Some(value) = resolved.get(&key) {
306            exported.insert(key, *value);
307        }
308    }
309
310    Ok(exported)
311}
312
313fn validate_import_unit(
314    unit: &ParsedUnit,
315    units_by_path: &HashMap<PathBuf, &ParsedUnit>,
316) -> Result<(), Error> {
317    let base_dir = unit.path.parent().unwrap_or_else(|| Path::new(""));
318    let local_namespace = namespace(&unit.file);
319    let mut symbols = local_symbols(&unit.file, &local_namespace);
320    let mut imported_type_suggestions = HashMap::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        let imported_namespace = namespace(&imported.file);
332        let imported_names = declared_names(&imported.file);
333        for name in &imported_names {
334            if !imported_namespace.is_empty() {
335                let mut qualified = imported_namespace.clone();
336                qualified.push(name.clone());
337                imported_type_suggestions.insert(name.clone(), qualified.join("::"));
338            }
339        }
340        symbols.extend(qualified_symbols(&imported_names, &imported_namespace));
341    }
342
343    validate_type_refs(&unit.file, &symbols, &imported_type_suggestions)
344}
345
346fn output_path_for(input: &Path, out_dir: &Path, lang: Lang) -> Result<PathBuf, Error> {
347    let stem = input.file_stem().ok_or_else(|| {
348        std::io::Error::new(
349            std::io::ErrorKind::InvalidInput,
350            format!("input file has no stem: {}", input.display()),
351        )
352    })?;
353    Ok(out_dir.join(format!("{}.{}", stem.to_string_lossy(), lang.extension())))
354}
355
356fn namespace(file: &SynFile) -> Vec<String> {
357    file.items
358        .iter()
359        .find_map(|item| match item {
360            Item::Namespace(ns) => Some(ns.name.clone()),
361            _ => None,
362        })
363        .unwrap_or_default()
364}
365
366fn local_symbols(file: &SynFile, namespace: &[String]) -> HashSet<Vec<String>> {
367    let mut symbols = HashSet::new();
368    for name in declared_names(file) {
369        symbols.insert(vec![name.clone()]);
370        if !namespace.is_empty() {
371            let mut qualified = namespace.to_vec();
372            qualified.push(name);
373            symbols.insert(qualified);
374        }
375    }
376    symbols
377}
378
379fn qualified_symbols(names: &[String], namespace: &[String]) -> HashSet<Vec<String>> {
380    let mut symbols = HashSet::new();
381    if namespace.is_empty() {
382        return symbols;
383    }
384    for name in names {
385        let mut qualified = namespace.to_vec();
386        qualified.push(name.clone());
387        symbols.insert(qualified);
388    }
389    symbols
390}
391
392fn declared_names(file: &SynFile) -> Vec<String> {
393    file.items
394        .iter()
395        .filter_map(|item| match item {
396            Item::Const(c) => Some(c.name.clone()),
397            Item::Enum(e) => Some(e.name.clone()),
398            Item::Struct(s) | Item::Table(s) => Some(s.name.clone()),
399            Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => Some(m.name.clone()),
400            Item::Namespace(_) | Item::Import(_) => None,
401        })
402        .collect()
403}
404
405fn validate_type_refs(
406    file: &SynFile,
407    symbols: &HashSet<Vec<String>>,
408    imported_type_suggestions: &HashMap<String, String>,
409) -> Result<(), Error> {
410    for item in &file.items {
411        match item {
412            Item::Const(c) => {
413                validate_type_ref(&c.name, &c.ty.base, symbols, imported_type_suggestions)?
414            }
415            Item::Struct(s) | Item::Table(s) => {
416                validate_field_refs(&s.name, &s.fields, symbols, imported_type_suggestions)?
417            }
418            Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => {
419                validate_field_refs(&m.name, &m.fields, symbols, imported_type_suggestions)?
420            }
421            Item::Namespace(_) | Item::Import(_) | Item::Enum(_) => {}
422        }
423    }
424    Ok(())
425}
426
427fn validate_field_refs(
428    container: &str,
429    fields: &[FieldDef],
430    symbols: &HashSet<Vec<String>>,
431    imported_type_suggestions: &HashMap<String, String>,
432) -> Result<(), Error> {
433    for field in fields {
434        validate_type_ref(
435            &format!("{container}.{}", field.name),
436            &field.ty.base,
437            symbols,
438            imported_type_suggestions,
439        )?;
440    }
441    Ok(())
442}
443
444fn validate_type_ref(
445    owner: &str,
446    base: &BaseType,
447    symbols: &HashSet<Vec<String>>,
448    imported_type_suggestions: &HashMap<String, String>,
449) -> Result<(), Error> {
450    let BaseType::Ref(segments) = base else {
451        return Ok(());
452    };
453    if symbols.contains(segments) {
454        return Ok(());
455    }
456    if segments.len() == 1 {
457        if let Some(suggestion) = imported_type_suggestions.get(&segments[0]) {
458            return Err(Error::Import(format!(
459                "imported type reference `{}` in `{owner}` must be namespace-qualified as `{suggestion}`",
460                segments[0]
461            )));
462        }
463    }
464    Err(Error::Import(format!(
465        "unresolved type reference `{}` in `{owner}`",
466        segments.join("::")
467    )))
468}
469
470/// Generate a cFS C header from an input file.
471pub fn generate_c_file(
472    input: impl AsRef<Path>,
473    out_dir: impl AsRef<Path>,
474) -> Result<PathBuf, Error> {
475    generate_file(input, out_dir, Lang::C)
476}
477
478/// Generate Rust `#[repr(C)]` bindings from an input file.
479pub fn generate_rust_file(
480    input: impl AsRef<Path>,
481    out_dir: impl AsRef<Path>,
482) -> Result<PathBuf, Error> {
483    generate_file(input, out_dir, Lang::Rust)
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use std::time::{SystemTime, UNIX_EPOCH};
490
491    #[test]
492    fn generate_c_from_string() {
493        let out = generate_str(
494            "@mid(0x1880)\n@cc(1)\ncommand SetMode { mode: u8 }",
495            Lang::C,
496        )
497        .unwrap();
498        assert!(out.contains("#define SET_MODE_MID  0x1880U"));
499        assert!(out.contains("#define SET_MODE_CC   1U"));
500        assert!(out.contains("CFE_MSG_CommandHeader_t Header;"));
501    }
502
503    #[test]
504    fn generate_rust_from_string() {
505        let out = generate_str("@mid(0x0801)\ntelemetry NavState { x: f64 }", Lang::Rust).unwrap();
506        assert!(out.contains("pub const NAV_STATE_MID: u16 = 0x0801;"));
507        assert!(out.contains("pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
508    }
509
510    #[test]
511    fn rejects_optional_fields() {
512        let err = generate_str(
513            "@mid(0x0801)\ntelemetry Status { error_code?: u32 }",
514            Lang::C,
515        )
516        .unwrap_err();
517        assert_eq!(
518            err.to_string(),
519            "optional field `Status.error_code` is not supported by cFS codegen yet"
520        );
521    }
522
523    #[test]
524    fn rejects_default_values() {
525        let err = generate_str("table Config { exposure_us: u32 = 10000 }", Lang::C).unwrap_err();
526        assert_eq!(
527            err.to_string(),
528            "default value for field `Config.exposure_us` is not supported by cFS codegen yet"
529        );
530    }
531
532    #[test]
533    fn rejects_enum_fields() {
534        let err = generate_str(
535            "enum CameraMode { Idle = 0 Streaming = 1 }\n@mid(0x0801)\ntelemetry Status { mode: CameraMode }",
536            Lang::C,
537        )
538        .unwrap_err();
539        assert_eq!(
540            err.to_string(),
541            "enum field `Status.mode` with type `CameraMode` needs an explicit integer representation for cFS codegen"
542        );
543    }
544
545    #[test]
546    fn rejects_unbounded_strings() {
547        let err = generate_str("struct Label { name: string }", Lang::C).unwrap_err();
548        assert_eq!(
549            err.to_string(),
550            "unbounded string field `Label.name` is not supported by cFS codegen; use `string[<=N]` or `string[N]`"
551        );
552    }
553
554    #[test]
555    fn rejects_legacy_message() {
556        let err = generate_str("@mid(0x0801)\nmessage Status { x: f32 }", Lang::C).unwrap_err();
557        assert_eq!(
558            err.to_string(),
559            "legacy message `Status` is not supported by cFS codegen; use `command` or `telemetry`"
560        );
561    }
562
563    #[test]
564    fn rejects_packet_without_mid() {
565        let err = generate_str("command SetMode { mode: u8 }", Lang::C).unwrap_err();
566        assert_eq!(
567            err.to_string(),
568            "packet `SetMode` is missing required `@mid(...)`"
569        );
570    }
571
572    #[test]
573    fn rejects_command_without_cc() {
574        let err = generate_str("@mid(0x1880)\ncommand SetMode { mode: u8 }", Lang::C).unwrap_err();
575        assert_eq!(
576            err.to_string(),
577            "command `SetMode` is missing required `@cc(...)`"
578        );
579    }
580
581    #[test]
582    fn rejects_mid_range_mismatch() {
583        let err = generate_str(
584            "@mid(0x0801)\n@cc(1)\ncommand SetMode { mode: u8 }",
585            Lang::C,
586        )
587        .unwrap_err();
588        assert_eq!(
589            err.to_string(),
590            "packet `SetMode` has MID `0x0801U`, expected command MID with bit 0x1000 set"
591        );
592    }
593
594    #[test]
595    fn rejects_dynamic_arrays() {
596        let err =
597            generate_str("@mid(0x0801)\ntelemetry Samples { values: f32[] }", Lang::C).unwrap_err();
598        assert_eq!(
599            err.to_string(),
600            "dynamic array field `Samples.values` with type `f32[]` is not supported by cFS codegen yet"
601        );
602    }
603
604    #[test]
605    fn rejects_non_string_bounded_arrays() {
606        let err = generate_str("table Buffer { bytes: u8[<=256] }", Lang::C).unwrap_err();
607        assert_eq!(
608            err.to_string(),
609            "bounded array field `Buffer.bytes` with type `u8[<=256]` is not supported by cFS codegen yet"
610        );
611    }
612
613    #[test]
614    fn generate_path_validates_imported_type_refs() {
615        let dir = test_dir("validates-imported-type-refs");
616        fs::write(
617            dir.join("std_msgs.syn"),
618            "namespace std_msgs\nstruct Header { seq: u32 }",
619        )
620        .unwrap();
621        let input = dir.join("camera.syn");
622        fs::write(
623            &input,
624            r#"namespace camera_app
625import "std_msgs.syn"
626@mid(0x0881)
627telemetry CameraStatus {
628    header: std_msgs::Header
629}
630"#,
631        )
632        .unwrap();
633
634        let out = generate_path(&input, Lang::C).unwrap();
635        assert!(out.contains("#include \"std_msgs.h\""));
636        assert!(out.contains("std_msgs_Header_t header;"));
637    }
638
639    #[test]
640    fn generate_path_resolves_imported_constants_in_attrs() {
641        let dir = test_dir("resolves-imported-constants-in-attrs");
642        fs::write(
643            dir.join("nav_ids.syn"),
644            "namespace nav_ids\nconst NAV_STATE_MID: u16 = 0x0801",
645        )
646        .unwrap();
647        let input = dir.join("nav.syn");
648        fs::write(
649            &input,
650            r#"namespace nav_app
651import "nav_ids.syn"
652@mid(nav_ids::NAV_STATE_MID)
653telemetry NavState {
654    x: f64
655}
656"#,
657        )
658        .unwrap();
659
660        let out = generate_path(&input, Lang::C).unwrap();
661        assert!(out.contains("#define NAV_STATE_MID  0x0801U"));
662    }
663
664    #[test]
665    fn generate_path_resolves_imported_alias_constants_in_attrs() {
666        let dir = test_dir("resolves-imported-alias-constants-in-attrs");
667        fs::write(
668            dir.join("mission_ids.syn"),
669            "namespace mission_ids\nconst NAV_CMD_MID: u16 = 0x1880",
670        )
671        .unwrap();
672        fs::write(
673            dir.join("nav_ids.syn"),
674            r#"namespace nav_ids
675import "mission_ids.syn"
676const SET_MODE_MID: u16 = mission_ids::NAV_CMD_MID
677const SET_MODE_CC: u16 = 2
678"#,
679        )
680        .unwrap();
681        let input = dir.join("nav.syn");
682        fs::write(
683            &input,
684            r#"namespace nav_app
685import "nav_ids.syn"
686@mid(nav_ids::SET_MODE_MID)
687@cc(nav_ids::SET_MODE_CC)
688command SetMode {
689    mode: u8
690}
691"#,
692        )
693        .unwrap();
694
695        let out = generate_path(&input, Lang::Rust).unwrap();
696        assert!(out.contains("pub const SET_MODE_MID: u16 = 0x1880;"));
697        assert!(out.contains("pub const SET_MODE_CC: u16 = 2;"));
698    }
699
700    #[test]
701    fn generate_path_rejects_transitive_only_constant_refs() {
702        let dir = test_dir("rejects-transitive-only-constant-refs");
703        fs::write(
704            dir.join("mission_ids.syn"),
705            "namespace mission_ids\nconst NAV_STATE_MID: u16 = 0x0801",
706        )
707        .unwrap();
708        fs::write(
709            dir.join("nav_ids.syn"),
710            r#"namespace nav_ids
711import "mission_ids.syn"
712const LOCAL_MID: u16 = mission_ids::NAV_STATE_MID
713"#,
714        )
715        .unwrap();
716        let input = dir.join("nav.syn");
717        fs::write(
718            &input,
719            r#"namespace nav_app
720import "nav_ids.syn"
721@mid(mission_ids::NAV_STATE_MID)
722telemetry NavState {
723    x: f64
724}
725"#,
726        )
727        .unwrap();
728
729        let err = generate_path(&input, Lang::C).unwrap_err();
730        assert_eq!(
731            err.to_string(),
732            "packet `NavState` has unresolved or non-integer `@mid(...)`; cFS codegen requires an integer, hex, local integer constant, or imported integer constant message ID"
733        );
734    }
735
736    #[test]
737    fn generate_path_rejects_unqualified_imported_type_refs() {
738        let dir = test_dir("rejects-unqualified-imported-type-refs");
739        fs::write(
740            dir.join("std_msgs.syn"),
741            "namespace std_msgs\nstruct Header { seq: u32 }",
742        )
743        .unwrap();
744        let input = dir.join("camera.syn");
745        fs::write(
746            &input,
747            r#"namespace camera_app
748import "std_msgs.syn"
749@mid(0x0881)
750telemetry CameraStatus {
751    header: Header
752}
753"#,
754        )
755        .unwrap();
756
757        let err = generate_path(&input, Lang::C).unwrap_err();
758        assert_eq!(
759            err.to_string(),
760            "imported type reference `Header` in `CameraStatus.header` must be namespace-qualified as `std_msgs::Header`"
761        );
762    }
763
764    #[test]
765    fn generate_path_rejects_missing_import_file() {
766        let dir = test_dir("missing-import");
767        let input = dir.join("camera.syn");
768        fs::write(&input, r#"import "missing.syn""#).unwrap();
769
770        let err = generate_path(&input, Lang::C).unwrap_err();
771        assert!(err.to_string().contains("error reading import"));
772        assert!(err.to_string().contains("missing.syn"));
773    }
774
775    #[test]
776    fn generate_path_rejects_unresolved_type_ref() {
777        let dir = test_dir("unresolved-type-ref");
778        let input = dir.join("camera.syn");
779        fs::write(
780            &input,
781            "@mid(0x0881)\ntelemetry CameraStatus { header: std_msgs::Header }",
782        )
783        .unwrap();
784
785        let err = generate_path(&input, Lang::C).unwrap_err();
786        assert_eq!(
787            err.to_string(),
788            "unresolved type reference `std_msgs::Header` in `CameraStatus.header`"
789        );
790    }
791
792    #[test]
793    fn generate_path_validates_transitive_imports() {
794        let dir = test_dir("validates-transitive-imports");
795        fs::write(
796            dir.join("time.syn"),
797            "namespace time\nstruct Time { sec: u32 }",
798        )
799        .unwrap();
800        fs::write(
801            dir.join("std_msgs.syn"),
802            r#"namespace std_msgs
803import "time.syn"
804struct Header { stamp: time::Time }
805"#,
806        )
807        .unwrap();
808        let input = dir.join("camera.syn");
809        fs::write(
810            &input,
811            r#"namespace camera_app
812import "std_msgs.syn"
813@mid(0x0881)
814telemetry CameraStatus {
815    header: std_msgs::Header
816}
817"#,
818        )
819        .unwrap();
820
821        let out = generate_path(&input, Lang::C).unwrap();
822        assert!(out.contains("#include \"std_msgs.h\""));
823        assert!(out.contains("std_msgs_Header_t header;"));
824    }
825
826    #[test]
827    fn generate_path_rejects_missing_transitive_import_file() {
828        let dir = test_dir("missing-transitive-import");
829        fs::write(
830            dir.join("std_msgs.syn"),
831            r#"namespace std_msgs
832import "missing_time.syn"
833struct Header { seq: u32 }
834"#,
835        )
836        .unwrap();
837        let input = dir.join("camera.syn");
838        fs::write(
839            &input,
840            r#"namespace camera_app
841import "std_msgs.syn"
842@mid(0x0881)
843telemetry CameraStatus {
844    header: std_msgs::Header
845}
846"#,
847        )
848        .unwrap();
849
850        let err = generate_path(&input, Lang::C).unwrap_err();
851        assert!(err.to_string().contains("error reading import"));
852        assert!(err.to_string().contains("missing_time.syn"));
853    }
854
855    #[test]
856    fn generate_path_rejects_references_to_transitive_only_imports() {
857        let dir = test_dir("rejects-transitive-only-import-reference");
858        fs::write(
859            dir.join("time.syn"),
860            "namespace time\nstruct Time { sec: u32 }",
861        )
862        .unwrap();
863        fs::write(
864            dir.join("std_msgs.syn"),
865            r#"namespace std_msgs
866import "time.syn"
867struct Header { stamp: time::Time }
868"#,
869        )
870        .unwrap();
871        let input = dir.join("camera.syn");
872        fs::write(
873            &input,
874            r#"namespace camera_app
875import "std_msgs.syn"
876@mid(0x0881)
877telemetry CameraStatus {
878    stamp: time::Time
879}
880"#,
881        )
882        .unwrap();
883
884        let err = generate_path(&input, Lang::C).unwrap_err();
885        assert_eq!(
886            err.to_string(),
887            "unresolved type reference `time::Time` in `CameraStatus.stamp`"
888        );
889    }
890
891    #[test]
892    fn generate_files_writes_import_closure_in_dependency_order() {
893        let dir = test_dir("generate-files-import-closure");
894        fs::write(
895            dir.join("time.syn"),
896            "namespace time\nstruct Time { sec: u32 }",
897        )
898        .unwrap();
899        fs::write(
900            dir.join("std_msgs.syn"),
901            r#"namespace std_msgs
902import "time.syn"
903struct Header { stamp: time::Time }
904"#,
905        )
906        .unwrap();
907        let input = dir.join("camera.syn");
908        fs::write(
909            &input,
910            r#"namespace camera_app
911import "std_msgs.syn"
912@mid(0x0881)
913telemetry CameraStatus {
914    header: std_msgs::Header
915}
916"#,
917        )
918        .unwrap();
919
920        let out_dir = dir.join("generated");
921        let written = generate_files(&input, &out_dir, Lang::C).unwrap();
922        let names = written
923            .iter()
924            .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
925            .collect::<Vec<_>>();
926        assert_eq!(names, ["time.h", "std_msgs.h", "camera.h"]);
927        assert!(
928            fs::read_to_string(out_dir.join("std_msgs.h"))
929                .unwrap()
930                .contains("#include \"time.h\"")
931        );
932        assert!(
933            fs::read_to_string(out_dir.join("camera.h"))
934                .unwrap()
935                .contains("#include \"std_msgs.h\"")
936        );
937    }
938
939    #[test]
940    fn generate_path_rejects_import_cycles() {
941        let dir = test_dir("rejects-import-cycles");
942        fs::write(
943            dir.join("a.syn"),
944            r#"namespace a
945import "b.syn"
946struct A { b: b::B }
947"#,
948        )
949        .unwrap();
950        fs::write(
951            dir.join("b.syn"),
952            r#"namespace b
953import "a.syn"
954struct B { a: a::A }
955"#,
956        )
957        .unwrap();
958
959        let err = generate_path(dir.join("a.syn"), Lang::C).unwrap_err();
960        assert!(err.to_string().contains("import cycle detected"));
961        assert!(err.to_string().contains("a.syn"));
962        assert!(err.to_string().contains("b.syn"));
963    }
964
965    fn test_dir(name: &str) -> PathBuf {
966        let stamp = SystemTime::now()
967            .duration_since(UNIX_EPOCH)
968            .unwrap()
969            .as_nanos();
970        let dir =
971            std::env::temp_dir().join(format!("synapse-{name}-{}-{stamp}", std::process::id()));
972        fs::create_dir_all(&dir).unwrap();
973        dir
974    }
975}