Skip to main content

cfs_synapse/
lib.rs

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