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