Skip to main content

synapse_codegen_cfs/
lib.rs

1use std::{collections::HashMap, error::Error as StdError, fmt};
2
3use synapse_parser::ast::{
4    ArraySuffix, Attribute, BaseType, ConstDecl, EnumDef, FieldDef, Item, Literal, MessageDef,
5    PacketKind, PrimitiveType, StructDef, SynFile, TypeExpr,
6};
7
8// ── Public API ────────────────────────────────────────────────────────────────
9
10/// Deterministic banner included at the top of generated files.
11pub const GENERATED_BANNER: &str = "Generated by Synapse. Do not edit directly.";
12
13/// Preamble included at the top of generated cFS C headers.
14pub const PREAMBLE: &str = "\
15/* Generated by Synapse. Do not edit directly. */
16#pragma once
17#include \"cfe.h\"
18
19";
20
21/// Resolved integer constants visible to a file from imported namespaces.
22pub type ResolvedConstants = HashMap<Vec<String>, u64>;
23
24/// cFS packet category used by mission-wide validation.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CfsPacketKind {
27    /// cFS Software Bus command packet.
28    Command,
29    /// cFS Software Bus telemetry packet.
30    Telemetry,
31}
32
33/// Resolved cFS packet facts for registry-style validation.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct CfsPacket {
36    /// Namespace segments declared by the source file, if any.
37    pub namespace: Vec<String>,
38    /// Packet declaration name.
39    pub name: String,
40    /// Packet kind.
41    pub kind: CfsPacketKind,
42    /// Resolved numeric message ID.
43    pub mid: u64,
44    /// Resolved numeric command code for command packets.
45    pub cc: Option<u64>,
46}
47
48/// Options for Rust cFS binding generation.
49pub struct RustOptions<'a> {
50    /// Module path prefix for the cFS header types.
51    /// e.g. `"cfs"` → `cfs::TelemetryHeader`, `"cfe_sys"` → `cfe_sys::TelemetryHeader`.
52    /// Set to `""` to use bare type names.
53    pub cfs_module: &'a str,
54    /// Rust type name for telemetry message headers. Default: `"TelemetryHeader"`.
55    pub tlm_header: &'a str,
56    /// Rust type name for command message headers. Default: `"CommandHeader"`.
57    pub cmd_header: &'a str,
58}
59
60impl Default for RustOptions<'_> {
61    fn default() -> Self {
62        RustOptions {
63            cfs_module: "cfs_sys",
64            tlm_header: "CFE_MSG_TelemetryHeader_t",
65            cmd_header: "CFE_MSG_CommandHeader_t",
66        }
67    }
68}
69
70/// Error returned when a parsed Synapse file cannot be emitted safely.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum CodegenError {
73    /// Optional fields parse today, but cFS ABI codegen has no representation for them yet.
74    OptionalFieldUnsupported { container: String, field: String },
75    /// Field defaults parse today, but cFS ABI codegen does not generate initializers yet.
76    DefaultValueUnsupported { container: String, field: String },
77    /// Unrepresented enum fields parse today, but cFS ABI codegen needs an explicit representation.
78    EnumFieldUnsupported {
79        container: String,
80        field: String,
81        ty: String,
82    },
83    /// Represented enums must use integer ABI types.
84    EnumRepresentationUnsupported { enum_name: String, repr: String },
85    /// Represented enums require explicit values for every variant.
86    EnumVariantValueRequired { enum_name: String, variant: String },
87    /// Represented enum variant values must fit the selected ABI type.
88    EnumVariantValueOutOfRange {
89        enum_name: String,
90        variant: String,
91        value: i64,
92        repr: String,
93    },
94    /// Unbounded strings would generate pointer fields, which are not cFS packet/table ABI data.
95    UnboundedStringUnsupported { container: String, field: String },
96    /// The legacy `message` keyword is parsed for migration, but cFS codegen requires intent.
97    LegacyMessageUnsupported { packet: String },
98    /// cFS Software Bus command and telemetry packets require explicit message IDs.
99    MissingMid { packet: String },
100    /// Message IDs are only meaningful for cFS command and telemetry packets.
101    MessageIdUnsupported { item: String },
102    /// Message IDs must resolve to non-negative integers for cFS codegen.
103    MessageIdValueUnsupported { packet: String },
104    /// cFS command packets require an explicit command code.
105    MissingCommandCode { packet: String },
106    /// Command codes are only meaningful for cFS command packets.
107    CommandCodeUnsupported { item: String },
108    /// Command codes must be literal non-negative integers for cFS codegen today.
109    CommandCodeValueUnsupported { packet: String },
110    /// Literal MIDs must be unique within one generated file.
111    DuplicateMid {
112        mid: String,
113        first_packet: String,
114        second_packet: String,
115    },
116    /// Literal command MID/CC pairs must be unique within one generated file.
117    DuplicateCommandCode {
118        mid: String,
119        cc: String,
120        first_packet: String,
121        second_packet: String,
122    },
123    /// Literal command/telemetry MIDs must match the expected cFS command bit pattern.
124    MidRangeMismatch {
125        packet: String,
126        mid: String,
127        expected: &'static str,
128    },
129    /// Dynamic arrays parse today, but cFS ABI codegen has no ownership/length model yet.
130    DynamicArrayUnsupported {
131        container: String,
132        field: String,
133        ty: String,
134    },
135    /// Non-string bounded arrays parse today, but cFS ABI codegen has no inline representation yet.
136    BoundedArrayUnsupported {
137        container: String,
138        field: String,
139        ty: String,
140    },
141}
142
143impl fmt::Display for CodegenError {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self {
146            CodegenError::OptionalFieldUnsupported { container, field } => write!(
147                f,
148                "optional field `{container}.{field}` is not supported by cFS codegen yet"
149            ),
150            CodegenError::DefaultValueUnsupported { container, field } => write!(
151                f,
152                "default value for field `{container}.{field}` is not supported by cFS codegen yet"
153            ),
154            CodegenError::EnumFieldUnsupported {
155                container,
156                field,
157                ty,
158            } => write!(
159                f,
160                "enum field `{container}.{field}` with type `{ty}` needs an explicit integer representation for cFS codegen"
161            ),
162            CodegenError::EnumRepresentationUnsupported { enum_name, repr } => write!(
163                f,
164                "enum `{enum_name}` uses unsupported representation `{repr}`; cFS codegen supports integer enum representations"
165            ),
166            CodegenError::EnumVariantValueRequired { enum_name, variant } => write!(
167                f,
168                "enum `{enum_name}` variant `{variant}` needs an explicit value for cFS codegen"
169            ),
170            CodegenError::EnumVariantValueOutOfRange {
171                enum_name,
172                variant,
173                value,
174                repr,
175            } => write!(
176                f,
177                "enum `{enum_name}` variant `{variant}` value `{value}` does not fit `{repr}`"
178            ),
179            CodegenError::UnboundedStringUnsupported { container, field } => write!(
180                f,
181                "unbounded string field `{container}.{field}` is not supported by cFS codegen; use `string[<=N]` or `string[N]`"
182            ),
183            CodegenError::LegacyMessageUnsupported { packet } => write!(
184                f,
185                "legacy message `{packet}` is not supported by cFS codegen; use `command` or `telemetry`"
186            ),
187            CodegenError::MissingMid { packet } => {
188                write!(f, "packet `{packet}` is missing required `@mid(...)`")
189            }
190            CodegenError::MessageIdUnsupported { item } => write!(
191                f,
192                "`@mid(...)` is only supported on command and telemetry packets, found on `{item}`"
193            ),
194            CodegenError::MessageIdValueUnsupported { packet } => write!(
195                f,
196                "packet `{packet}` has unresolved or non-integer `@mid(...)`; cFS codegen requires an integer, hex, local integer constant, or imported integer constant message ID"
197            ),
198            CodegenError::MissingCommandCode { packet } => {
199                write!(f, "command `{packet}` is missing required `@cc(...)`")
200            }
201            CodegenError::CommandCodeUnsupported { item } => write!(
202                f,
203                "`@cc(...)` is only supported on command packets, found on `{item}`"
204            ),
205            CodegenError::CommandCodeValueUnsupported { packet } => write!(
206                f,
207                "command `{packet}` has unresolved or non-integer `@cc(...)`; cFS codegen requires an integer, hex, or local integer constant command code"
208            ),
209            CodegenError::DuplicateMid {
210                mid,
211                first_packet,
212                second_packet,
213            } => write!(
214                f,
215                "duplicate MID `{mid}` used by packets `{first_packet}` and `{second_packet}`"
216            ),
217            CodegenError::DuplicateCommandCode {
218                mid,
219                cc,
220                first_packet,
221                second_packet,
222            } => write!(
223                f,
224                "duplicate command MID/CC pair `{mid}`/`{cc}` used by packets `{first_packet}` and `{second_packet}`"
225            ),
226            CodegenError::MidRangeMismatch {
227                packet,
228                mid,
229                expected,
230            } => write!(f, "packet `{packet}` has MID `{mid}`, expected {expected}"),
231            CodegenError::DynamicArrayUnsupported {
232                container,
233                field,
234                ty,
235            } => write!(
236                f,
237                "dynamic array field `{container}.{field}` with type `{ty}` is not supported by cFS codegen yet"
238            ),
239            CodegenError::BoundedArrayUnsupported {
240                container,
241                field,
242                ty,
243            } => write!(
244                f,
245                "bounded array field `{container}.{field}` with type `{ty}` is not supported by cFS codegen yet"
246            ),
247        }
248    }
249}
250
251impl StdError for CodegenError {}
252
253/// Generate a NASA cFS C header (`*_msg.h` + MID `#define`s) from a parsed Synapse file.
254pub fn generate_c(file: &SynFile) -> String {
255    try_generate_c(file).expect("parsed Synapse file is not supported by cFS C codegen")
256}
257
258/// Try to generate a NASA cFS C header (`*_msg.h` + MID `#define`s`) from a parsed Synapse file.
259pub fn try_generate_c(file: &SynFile) -> Result<String, CodegenError> {
260    try_generate_c_with_constants(file, &ResolvedConstants::new())
261}
262
263/// Validate that a parsed Synapse file is supported by cFS code generation.
264pub fn validate_cfs(file: &SynFile) -> Result<(), CodegenError> {
265    validate_cfs_with_constants(file, &ResolvedConstants::new())
266}
267
268/// Validate cFS code generation support with additional imported constants available.
269pub fn validate_cfs_with_constants(
270    file: &SynFile,
271    imported_constants: &ResolvedConstants,
272) -> Result<(), CodegenError> {
273    let constants = const_context(file, imported_constants);
274    validate_supported(file, &constants)
275}
276
277/// Collect resolved cFS packet facts with additional imported constants available.
278///
279/// This validates packet-level attributes needed to resolve MIDs and command
280/// codes, but it does not validate fields or other cFS ABI constraints.
281pub fn collect_cfs_packets_with_constants(
282    file: &SynFile,
283    imported_constants: &ResolvedConstants,
284) -> Result<Vec<CfsPacket>, CodegenError> {
285    let constants = const_context(file, imported_constants);
286    collect_cfs_packets(file, &constants)
287}
288
289/// Try to generate a C header with additional imported constants available for attributes.
290pub fn try_generate_c_with_constants(
291    file: &SynFile,
292    imported_constants: &ResolvedConstants,
293) -> Result<String, CodegenError> {
294    let constants = const_context(file, imported_constants);
295    validate_supported(file, &constants)?;
296    let mut out = String::from(PREAMBLE);
297    emit_c_imports(file, &mut out);
298    emit_items(file, &mut out, &constants);
299    Ok(out)
300}
301
302/// Generate `#[repr(C)]` Rust structs compatible with NASA cFS bindings.
303///
304/// `command` and `telemetry` packets become structs with the cFS header as the
305/// first field, matching the C ABI layout. `struct` and `table` items remain
306/// plain data structs. MID constants are emitted as `pub const`.
307pub fn generate_rust(file: &SynFile, opts: &RustOptions) -> String {
308    try_generate_rust(file, opts).expect("parsed Synapse file is not supported by cFS Rust codegen")
309}
310
311/// Try to generate `#[repr(C)]` Rust structs compatible with NASA cFS bindings.
312pub fn try_generate_rust(file: &SynFile, opts: &RustOptions) -> Result<String, CodegenError> {
313    try_generate_rust_with_constants(file, opts, &ResolvedConstants::new())
314}
315
316/// Try to generate Rust bindings with additional imported constants available for attributes.
317pub fn try_generate_rust_with_constants(
318    file: &SynFile,
319    opts: &RustOptions,
320    imported_constants: &ResolvedConstants,
321) -> Result<String, CodegenError> {
322    let constants = const_context(file, imported_constants);
323    validate_supported(file, &constants)?;
324    let mut out = format!("// {GENERATED_BANNER}\n\n");
325    emit_rust_imports(file, &mut out);
326    emit_rust_items(file, opts, &mut out, &constants);
327    Ok(out)
328}
329
330/// Resolve this file's integer constants, including aliases to visible imported constants.
331pub fn resolve_integer_constants(
332    file: &SynFile,
333    imported_constants: &ResolvedConstants,
334) -> ResolvedConstants {
335    let constants = const_context(file, imported_constants);
336    constants.resolved_local_constants()
337}
338
339// ── Item emission ─────────────────────────────────────────────────────────────
340
341fn validate_supported(file: &SynFile, constants: &ConstContext<'_>) -> Result<(), CodegenError> {
342    let enum_defs = enum_defs(file);
343    let mut telemetry_mids = HashMap::new();
344    let mut command_codes = HashMap::new();
345    for item in &file.items {
346        match item {
347            Item::Struct(s) | Item::Table(s) => {
348                validate_plain_item_attrs(&s.name, &s.attrs)?;
349                validate_fields(&s.name, &s.fields, &enum_defs)?
350            }
351            Item::Command(m) | Item::Telemetry(m) => {
352                validate_packet(m, constants, &mut telemetry_mids, &mut command_codes)?;
353                validate_fields(&m.name, &m.fields, &enum_defs)?
354            }
355            Item::Message(m) => {
356                return Err(CodegenError::LegacyMessageUnsupported {
357                    packet: m.name.clone(),
358                });
359            }
360            Item::Enum(e) => validate_enum(e)?,
361            Item::Namespace(_) | Item::Import(_) | Item::Const(_) => {}
362        }
363    }
364    Ok(())
365}
366
367fn collect_cfs_packets(
368    file: &SynFile,
369    constants: &ConstContext<'_>,
370) -> Result<Vec<CfsPacket>, CodegenError> {
371    let namespace = file_namespace(file);
372    let mut packets = Vec::new();
373
374    for item in &file.items {
375        let packet = match item {
376            Item::Command(m) | Item::Telemetry(m) => m,
377            Item::Namespace(_)
378            | Item::Import(_)
379            | Item::Const(_)
380            | Item::Enum(_)
381            | Item::Struct(_)
382            | Item::Table(_)
383            | Item::Message(_) => continue,
384        };
385
386        let Some(mid) = find_mid_attr(&packet.attrs) else {
387            return Err(CodegenError::MissingMid {
388                packet: packet.name.clone(),
389            });
390        };
391        let mid_value = resolve_literal_to_u64(mid, constants).ok_or_else(|| {
392            CodegenError::MessageIdValueUnsupported {
393                packet: packet.name.clone(),
394            }
395        })?;
396        validate_mid_range(packet, mid_value, mid, constants)?;
397
398        let cc = find_cc_attr(&packet.attrs);
399        let (kind, cc_value) = match packet.kind {
400            PacketKind::Command => {
401                let cc = cc.ok_or_else(|| CodegenError::MissingCommandCode {
402                    packet: packet.name.clone(),
403                })?;
404                let cc_value = resolve_literal_to_u64(cc, constants).ok_or_else(|| {
405                    CodegenError::CommandCodeValueUnsupported {
406                        packet: packet.name.clone(),
407                    }
408                })?;
409                (CfsPacketKind::Command, Some(cc_value))
410            }
411            PacketKind::Telemetry => {
412                if cc.is_some() {
413                    return Err(CodegenError::CommandCodeUnsupported {
414                        item: packet.name.clone(),
415                    });
416                }
417                (CfsPacketKind::Telemetry, None)
418            }
419            PacketKind::Message => continue,
420        };
421
422        packets.push(CfsPacket {
423            namespace: namespace.clone(),
424            name: packet.name.clone(),
425            kind,
426            mid: mid_value,
427            cc: cc_value,
428        });
429    }
430
431    Ok(packets)
432}
433
434struct ConstContext<'a> {
435    local_defs: HashMap<Vec<String>, &'a ConstDecl>,
436    imported_values: &'a ResolvedConstants,
437}
438
439fn const_context<'a>(
440    file: &'a SynFile,
441    imported_values: &'a ResolvedConstants,
442) -> ConstContext<'a> {
443    let namespace = file_namespace(file);
444    let mut local_defs = HashMap::new();
445    for item in &file.items {
446        if let Item::Const(c) = item {
447            local_defs.insert(vec![c.name.clone()], c);
448            if !namespace.is_empty() {
449                let mut qualified = namespace.clone();
450                qualified.push(c.name.clone());
451                local_defs.insert(qualified, c);
452            }
453        }
454    }
455    ConstContext {
456        local_defs,
457        imported_values,
458    }
459}
460
461impl ConstContext<'_> {
462    fn resolved_local_constants(&self) -> ResolvedConstants {
463        self.local_defs
464            .keys()
465            .filter_map(|segments| {
466                resolve_ident_to_u64(segments, self).map(|value| (segments.clone(), value))
467            })
468            .collect()
469    }
470
471    fn is_local_bare_ident(&self, segments: &[String]) -> bool {
472        segments.len() == 1 && self.local_defs.contains_key(segments)
473    }
474}
475
476fn file_namespace(file: &SynFile) -> Vec<String> {
477    file.items
478        .iter()
479        .find_map(|item| match item {
480            Item::Namespace(ns) => Some(ns.name.clone()),
481            _ => None,
482        })
483        .unwrap_or_default()
484}
485
486fn enum_defs(file: &SynFile) -> HashMap<String, &EnumDef> {
487    file.items
488        .iter()
489        .filter_map(|item| match item {
490            Item::Enum(e) => Some((e.name.clone(), e)),
491            _ => None,
492        })
493        .collect()
494}
495
496fn validate_enum(e: &EnumDef) -> Result<(), CodegenError> {
497    let Some(repr) = e.repr else {
498        return Ok(());
499    };
500    let Some((min, max)) = enum_repr_range(repr) else {
501        return Err(CodegenError::EnumRepresentationUnsupported {
502            enum_name: e.name.clone(),
503            repr: primitive_name(repr).to_string(),
504        });
505    };
506
507    for variant in &e.variants {
508        let value = variant
509            .value
510            .ok_or_else(|| CodegenError::EnumVariantValueRequired {
511                enum_name: e.name.clone(),
512                variant: variant.name.clone(),
513            })?;
514        if value < min || value > max {
515            return Err(CodegenError::EnumVariantValueOutOfRange {
516                enum_name: e.name.clone(),
517                variant: variant.name.clone(),
518                value,
519                repr: primitive_name(repr).to_string(),
520            });
521        }
522    }
523    Ok(())
524}
525
526fn enum_repr_range(repr: PrimitiveType) -> Option<(i64, i64)> {
527    match repr {
528        PrimitiveType::I8 => Some((i8::MIN as i64, i8::MAX as i64)),
529        PrimitiveType::I16 => Some((i16::MIN as i64, i16::MAX as i64)),
530        PrimitiveType::I32 => Some((i32::MIN as i64, i32::MAX as i64)),
531        PrimitiveType::I64 => Some((i64::MIN, i64::MAX)),
532        PrimitiveType::U8 => Some((0, u8::MAX as i64)),
533        PrimitiveType::U16 => Some((0, u16::MAX as i64)),
534        PrimitiveType::U32 => Some((0, u32::MAX as i64)),
535        PrimitiveType::U64 => Some((0, i64::MAX)),
536        PrimitiveType::F32 | PrimitiveType::F64 | PrimitiveType::Bool | PrimitiveType::Bytes => {
537            None
538        }
539    }
540}
541
542fn validate_packet(
543    packet: &MessageDef,
544    constants: &ConstContext<'_>,
545    telemetry_mids: &mut HashMap<u64, String>,
546    command_codes: &mut HashMap<(u64, u64), String>,
547) -> Result<(), CodegenError> {
548    let Some(mid) = find_mid_attr(&packet.attrs) else {
549        return Err(CodegenError::MissingMid {
550            packet: packet.name.clone(),
551        });
552    };
553
554    let cc = find_cc_attr(&packet.attrs);
555    match packet.kind {
556        PacketKind::Command if cc.is_none() => {
557            return Err(CodegenError::MissingCommandCode {
558                packet: packet.name.clone(),
559            });
560        }
561        PacketKind::Telemetry if cc.is_some() => {
562            return Err(CodegenError::CommandCodeUnsupported {
563                item: packet.name.clone(),
564            });
565        }
566        PacketKind::Command | PacketKind::Telemetry | PacketKind::Message => {}
567    }
568    let cc_value = if packet.kind == PacketKind::Command {
569        let cc = cc.expect("command code was checked above");
570        Some(resolve_literal_to_u64(cc, constants).ok_or_else(|| {
571            CodegenError::CommandCodeValueUnsupported {
572                packet: packet.name.clone(),
573            }
574        })?)
575    } else {
576        None
577    };
578
579    let value = resolve_literal_to_u64(mid, constants).ok_or_else(|| {
580        CodegenError::MessageIdValueUnsupported {
581            packet: packet.name.clone(),
582        }
583    })?;
584
585    validate_mid_range(packet, value, mid, constants)?;
586    match packet.kind {
587        PacketKind::Command => {
588            let cc = cc.expect("command code was checked above");
589            let cc_value = cc_value.expect("command code value was checked above");
590            if let Some(first_packet) = command_codes.insert((value, cc_value), packet.name.clone())
591            {
592                return Err(CodegenError::DuplicateCommandCode {
593                    mid: literal_mid_str(mid, constants),
594                    cc: literal_cc_str(cc, constants),
595                    first_packet,
596                    second_packet: packet.name.clone(),
597                });
598            }
599        }
600        PacketKind::Telemetry => {
601            if let Some(first_packet) = telemetry_mids.insert(value, packet.name.clone()) {
602                return Err(CodegenError::DuplicateMid {
603                    mid: literal_mid_str(mid, constants),
604                    first_packet,
605                    second_packet: packet.name.clone(),
606                });
607            }
608        }
609        PacketKind::Message => {}
610    }
611
612    Ok(())
613}
614
615fn validate_plain_item_attrs(item_name: &str, attrs: &[Attribute]) -> Result<(), CodegenError> {
616    if find_mid_attr(attrs).is_some() {
617        return Err(CodegenError::MessageIdUnsupported {
618            item: item_name.to_string(),
619        });
620    }
621    if find_cc_attr(attrs).is_some() {
622        return Err(CodegenError::CommandCodeUnsupported {
623            item: item_name.to_string(),
624        });
625    }
626    Ok(())
627}
628
629fn validate_mid_range(
630    packet: &MessageDef,
631    value: u64,
632    mid: &Literal,
633    constants: &ConstContext<'_>,
634) -> Result<(), CodegenError> {
635    let command_bit_set = (value & 0x1000) != 0;
636    let expected = match packet.kind {
637        PacketKind::Command if !command_bit_set => Some("command MID with bit 0x1000 set"),
638        PacketKind::Telemetry if command_bit_set => Some("telemetry MID with bit 0x1000 clear"),
639        PacketKind::Command | PacketKind::Telemetry | PacketKind::Message => None,
640    };
641
642    if let Some(expected) = expected {
643        return Err(CodegenError::MidRangeMismatch {
644            packet: packet.name.clone(),
645            mid: literal_mid_str(mid, constants),
646            expected,
647        });
648    }
649
650    Ok(())
651}
652
653fn validate_fields(
654    container: &str,
655    fields: &[FieldDef],
656    enum_defs: &HashMap<String, &EnumDef>,
657) -> Result<(), CodegenError> {
658    for field in fields {
659        if field.optional {
660            return Err(CodegenError::OptionalFieldUnsupported {
661                container: container.to_string(),
662                field: field.name.clone(),
663            });
664        }
665        if field.default.is_some() {
666            return Err(CodegenError::DefaultValueUnsupported {
667                container: container.to_string(),
668                field: field.name.clone(),
669            });
670        }
671        if field.ty.base == BaseType::String && field.ty.array.is_none() {
672            return Err(CodegenError::UnboundedStringUnsupported {
673                container: container.to_string(),
674                field: field.name.clone(),
675            });
676        }
677        if let BaseType::Ref(segments) = &field.ty.base {
678            if let Some(e) = segments
679                .last()
680                .and_then(|name| enum_defs.get(name.as_str()))
681            {
682                if e.repr.is_none() {
683                    return Err(CodegenError::EnumFieldUnsupported {
684                        container: container.to_string(),
685                        field: field.name.clone(),
686                        ty: segments.join("::"),
687                    });
688                }
689            }
690        }
691        match &field.ty.array {
692            Some(ArraySuffix::Dynamic) => {
693                return Err(CodegenError::DynamicArrayUnsupported {
694                    container: container.to_string(),
695                    field: field.name.clone(),
696                    ty: type_expr_display(&field.ty),
697                });
698            }
699            Some(ArraySuffix::Bounded(_)) if field.ty.base != BaseType::String => {
700                return Err(CodegenError::BoundedArrayUnsupported {
701                    container: container.to_string(),
702                    field: field.name.clone(),
703                    ty: type_expr_display(&field.ty),
704                });
705            }
706            Some(ArraySuffix::Bounded(_)) | Some(ArraySuffix::Fixed(_)) | None => {}
707        }
708    }
709    Ok(())
710}
711
712fn emit_c_imports(file: &SynFile, out: &mut String) {
713    let mut emitted = false;
714    for item in &file.items {
715        if let Item::Import(import) = item {
716            out.push_str(&format!("#include \"{}\"\n", import_c_header(&import.path)));
717            emitted = true;
718        }
719    }
720    if emitted {
721        out.push('\n');
722    }
723}
724
725fn emit_rust_imports(file: &SynFile, out: &mut String) {
726    let mut emitted = false;
727    for item in &file.items {
728        if let Item::Import(import) = item {
729            out.push_str(&format!(
730                "use crate::{};\n",
731                import_rust_module(&import.path)
732            ));
733            emitted = true;
734        }
735    }
736    if emitted {
737        out.push('\n');
738    }
739}
740
741fn emit_items(file: &SynFile, out: &mut String, constants: &ConstContext<'_>) {
742    // First pass: emit #define MID lines for Software Bus packets with @mid
743    let mut has_mids = false;
744    for item in &file.items {
745        if let Some(m) = packet_item(item) {
746            if let Some(mid) = find_mid_attr(&m.attrs) {
747                if !has_mids {
748                    out.push_str("/* Message IDs */\n");
749                    has_mids = true;
750                }
751                let define_name = to_screaming_snake(&m.name);
752                let mid_str = literal_mid_str(mid, constants);
753                out.push_str(&format!("#define {}_MID  {}\n", define_name, mid_str));
754            }
755        }
756    }
757    if has_mids {
758        out.push('\n');
759    }
760
761    let mut has_ccs = false;
762    for item in &file.items {
763        if let Item::Command(m) = item {
764            if let Some(cc) = find_cc_attr(&m.attrs) {
765                if !has_ccs {
766                    out.push_str("/* Command Codes */\n");
767                    has_ccs = true;
768                }
769                let define_name = to_screaming_snake(&m.name);
770                let cc_str = literal_cc_str(cc, constants);
771                out.push_str(&format!("#define {}_CC   {}\n", define_name, cc_str));
772            }
773        }
774    }
775    if has_ccs {
776        out.push('\n');
777    }
778
779    // Second pass: emit enum aliases before any struct fields can reference them.
780    let mut namespace = Vec::new();
781    for item in &file.items {
782        match item {
783            Item::Namespace(ns) => namespace = ns.name.clone(),
784            Item::Enum(e) => emit_enum(out, e, &namespace),
785            Item::Import(_)
786            | Item::Const(_)
787            | Item::Struct(_)
788            | Item::Table(_)
789            | Item::Command(_)
790            | Item::Telemetry(_)
791            | Item::Message(_) => {}
792        }
793    }
794
795    // Third pass: emit const, struct, and message types.
796    let mut namespace = Vec::new();
797    for item in &file.items {
798        match item {
799            Item::Namespace(ns) => namespace = ns.name.clone(),
800            Item::Import(_) | Item::Enum(_) => {}
801            Item::Const(c) => emit_const(out, c),
802            Item::Struct(s) | Item::Table(s) => emit_struct(out, s, &namespace),
803            Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => {
804                emit_message(out, m, &namespace)
805            }
806        }
807    }
808}
809
810// ── Const ─────────────────────────────────────────────────────────────────────
811
812fn emit_const(out: &mut String, c: &ConstDecl) {
813    emit_doc_lines(out, &c.doc);
814    let val = typed_literal_str(&c.value, &c.ty);
815    out.push_str(&format!("#define {}  {}\n\n", c.name, val));
816}
817
818// ── Enum ─────────────────────────────────────────────────────────────────────
819
820fn emit_enum(out: &mut String, e: &EnumDef, namespace: &[String]) {
821    let Some(repr) = e.repr else {
822        return;
823    };
824
825    let type_name = c_decl_type_name(&e.name, namespace);
826    emit_doc_lines(out, &e.doc);
827    out.push_str(&format!("typedef {} {};\n", primitive_str(repr), type_name));
828
829    let enum_prefix = c_enum_variant_prefix(&e.name, namespace);
830    for variant in &e.variants {
831        emit_doc_lines(out, &variant.doc);
832        let value = variant
833            .value
834            .expect("represented enum variants validated before emission");
835        out.push_str(&format!(
836            "#define {}_{}  (({}){})\n",
837            enum_prefix,
838            to_screaming_snake(&variant.name),
839            type_name,
840            value
841        ));
842    }
843    out.push('\n');
844}
845
846// ── Struct (plain supporting type, no cFS header) ─────────────────────────────
847
848fn emit_struct(out: &mut String, s: &StructDef, namespace: &[String]) {
849    emit_doc_lines(out, &s.doc);
850    out.push_str("typedef struct {\n");
851    for f in &s.fields {
852        emit_c_field(out, f, namespace);
853    }
854    out.push_str(&format!("}} {};\n\n", c_decl_type_name(&s.name, namespace)));
855}
856
857// ── Message ───────────────────────────────────────────────────────────────────
858
859fn emit_message(out: &mut String, m: &MessageDef, namespace: &[String]) {
860    let header_type = if packet_is_command(m) {
861        "CFE_MSG_CommandHeader_t"
862    } else {
863        "CFE_MSG_TelemetryHeader_t"
864    };
865
866    emit_doc_lines(out, &m.doc);
867
868    out.push_str(&format!("typedef struct {{\n"));
869    out.push_str(&format!("    {} Header;\n", header_type));
870    for f in &m.fields {
871        emit_c_field(out, f, namespace);
872    }
873    out.push_str(&format!("}} {};\n\n", c_decl_type_name(&m.name, namespace)));
874}
875
876// ── Rust emission ─────────────────────────────────────────────────────────────
877
878fn emit_rust_items(
879    file: &SynFile,
880    opts: &RustOptions,
881    out: &mut String,
882    constants: &ConstContext<'_>,
883) {
884    // First pass: MID consts for Software Bus packets with @mid
885    let mut has_mids = false;
886    for item in &file.items {
887        if let Some(m) = packet_item(item) {
888            if let Some(mid) = find_mid_attr(&m.attrs) {
889                if !has_mids {
890                    out.push_str("// Message IDs\n");
891                    has_mids = true;
892                }
893                let const_name = format!("{}_MID", to_screaming_snake(&m.name));
894                let val = rust_mid_str(mid, constants);
895                out.push_str(&format!("pub const {}: u16 = {};\n", const_name, val));
896            }
897        }
898    }
899    if has_mids {
900        out.push('\n');
901    }
902
903    let mut has_ccs = false;
904    for item in &file.items {
905        if let Item::Command(m) = item {
906            if let Some(cc) = find_cc_attr(&m.attrs) {
907                if !has_ccs {
908                    out.push_str("// Command Codes\n");
909                    has_ccs = true;
910                }
911                let const_name = format!("{}_CC", to_screaming_snake(&m.name));
912                let val = rust_cc_str(cc, constants);
913                out.push_str(&format!("pub const {}: u16 = {};\n", const_name, val));
914            }
915        }
916    }
917    if has_ccs {
918        out.push('\n');
919    }
920
921    // Second pass: types
922    for item in &file.items {
923        match item {
924            Item::Namespace(_) | Item::Import(_) => {}
925            Item::Const(c) => emit_rust_const(out, c),
926            Item::Enum(e) => emit_rust_enum(out, e),
927            Item::Struct(s) | Item::Table(s) => emit_rust_struct(out, s),
928            Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => {
929                emit_rust_message(out, m, opts)
930            }
931        }
932    }
933}
934
935fn emit_rust_const(out: &mut String, c: &ConstDecl) {
936    emit_doc_lines(out, &c.doc);
937    let val = rust_typed_literal_str(&c.value, &c.ty);
938    let ty = rust_field_type_str(&c.ty);
939    out.push_str(&format!("pub const {}: {} = {};\n\n", c.name, ty, val));
940}
941
942fn emit_rust_enum(out: &mut String, e: &EnumDef) {
943    let Some(repr) = e.repr else {
944        return;
945    };
946
947    emit_doc_lines(out, &e.doc);
948    out.push_str(&format!(
949        "pub type {} = {};\n",
950        e.name,
951        rust_primitive_str(repr)
952    ));
953
954    let enum_prefix = to_screaming_snake(&e.name);
955    for variant in &e.variants {
956        emit_doc_lines(out, &variant.doc);
957        let value = variant
958            .value
959            .expect("represented enum variants validated before emission");
960        out.push_str(&format!(
961            "pub const {}_{}: {} = {};\n",
962            enum_prefix,
963            to_screaming_snake(&variant.name),
964            e.name,
965            value
966        ));
967    }
968    out.push('\n');
969}
970
971fn emit_rust_struct(out: &mut String, s: &StructDef) {
972    emit_doc_lines(out, &s.doc);
973    out.push_str("#[repr(C)]\n");
974    out.push_str(&format!("pub struct {} {{\n", s.name));
975    for f in &s.fields {
976        emit_indented_doc_lines(out, &f.doc);
977        out.push_str(&format!(
978            "    pub {}: {},\n",
979            f.name,
980            rust_field_type_str(&f.ty)
981        ));
982    }
983    out.push_str("}\n\n");
984}
985
986fn emit_rust_message(out: &mut String, m: &MessageDef, opts: &RustOptions) {
987    let header_type = if packet_is_command(m) {
988        opts.cmd_header
989    } else {
990        opts.tlm_header
991    };
992    let qualified = if opts.cfs_module.is_empty() {
993        header_type.to_string()
994    } else {
995        format!("{}::{}", opts.cfs_module, header_type)
996    };
997
998    emit_doc_lines(out, &m.doc);
999
1000    out.push_str("#[repr(C)]\n");
1001    out.push_str(&format!("pub struct {} {{\n", m.name));
1002    out.push_str(&format!("    pub cfs_header: {},\n", qualified));
1003    for f in &m.fields {
1004        emit_indented_doc_lines(out, &f.doc);
1005        let ty = rust_field_type_str(&f.ty);
1006        out.push_str(&format!("    pub {}: {},\n", f.name, ty));
1007    }
1008    out.push_str("}\n\n");
1009}
1010
1011fn rust_field_type_str(ty: &TypeExpr) -> String {
1012    if ty.base == BaseType::String {
1013        return match &ty.array {
1014            None | Some(ArraySuffix::Dynamic) => "*const u8".to_string(),
1015            Some(ArraySuffix::Fixed(n)) | Some(ArraySuffix::Bounded(n)) => {
1016                format!("[u8; {}]", n)
1017            }
1018        };
1019    }
1020
1021    let base = rust_base_type_str(&ty.base);
1022    match &ty.array {
1023        None => base,
1024        Some(ArraySuffix::Fixed(n)) => format!("[{}; {}]", base, n),
1025        // Dynamic/bounded: use a raw slice pointer — no alloc in cFS context
1026        Some(ArraySuffix::Dynamic) => format!("*const {}", base),
1027        Some(ArraySuffix::Bounded(n)) => format!("*const {}  /* max {} */", base, n),
1028    }
1029}
1030
1031fn rust_base_type_str(base: &BaseType) -> String {
1032    match base {
1033        BaseType::String => "*const u8".to_string(),
1034        BaseType::Primitive(p) => rust_primitive_str(*p).to_string(),
1035        BaseType::Ref(segments) => segments.join("::"),
1036    }
1037}
1038
1039fn rust_primitive_str(p: PrimitiveType) -> &'static str {
1040    match p {
1041        PrimitiveType::F32 => "f32",
1042        PrimitiveType::F64 => "f64",
1043        PrimitiveType::I8 => "i8",
1044        PrimitiveType::I16 => "i16",
1045        PrimitiveType::I32 => "i32",
1046        PrimitiveType::I64 => "i64",
1047        PrimitiveType::U8 => "u8",
1048        PrimitiveType::U16 => "u16",
1049        PrimitiveType::U32 => "u32",
1050        PrimitiveType::U64 => "u64",
1051        PrimitiveType::Bool => "bool",
1052        PrimitiveType::Bytes => "*const u8",
1053    }
1054}
1055
1056fn rust_mid_str(lit: &Literal, constants: &ConstContext<'_>) -> String {
1057    match lit {
1058        Literal::Hex(n) => format!("0x{:04X}", n),
1059        Literal::Int(n) => n.to_string(),
1060        Literal::Ident(segs) if constants.is_local_bare_ident(segs) => segs.join("::"),
1061        Literal::Ident(segs) => resolve_ident_to_u64(segs, constants)
1062            .map(|value| format!("0x{:04X}", value))
1063            .unwrap_or_else(|| segs.join("::")),
1064        other => rust_literal_str(other),
1065    }
1066}
1067
1068fn rust_cc_str(lit: &Literal, constants: &ConstContext<'_>) -> String {
1069    match lit {
1070        Literal::Hex(n) => format!("0x{:X}", n),
1071        Literal::Int(n) => n.to_string(),
1072        Literal::Ident(segs) if constants.is_local_bare_ident(segs) => segs.join("::"),
1073        Literal::Ident(segs) => resolve_ident_to_u64(segs, constants)
1074            .map(|value| value.to_string())
1075            .unwrap_or_else(|| segs.join("::")),
1076        other => rust_literal_str(other),
1077    }
1078}
1079
1080fn rust_literal_str(lit: &Literal) -> String {
1081    match lit {
1082        Literal::Hex(n) => format!("0x{:X}", n),
1083        Literal::Int(n) => n.to_string(),
1084        Literal::Bool(b) => b.to_string(),
1085        Literal::Float(f) => {
1086            let s = format!("{}", f);
1087            if s.contains('.') || s.contains('e') {
1088                s
1089            } else {
1090                format!("{}.0", s)
1091            }
1092        }
1093        Literal::Str(s) => format!("{:?}", s),
1094        Literal::Ident(segments) => segments.join("::"),
1095    }
1096}
1097
1098fn rust_typed_literal_str(lit: &Literal, ty: &TypeExpr) -> String {
1099    match (lit, &ty.base) {
1100        (Literal::Hex(n), BaseType::Primitive(p)) => rust_hex_str(*n, *p),
1101        _ => rust_literal_str(lit),
1102    }
1103}
1104
1105fn rust_hex_str(value: u64, ty: PrimitiveType) -> String {
1106    match ty {
1107        PrimitiveType::U8 | PrimitiveType::I8 => format!("0x{:02X}", value),
1108        PrimitiveType::U16 | PrimitiveType::I16 => format!("0x{:04X}", value),
1109        PrimitiveType::U32 | PrimitiveType::I32 => format!("0x{:08X}", value),
1110        PrimitiveType::U64 | PrimitiveType::I64 => format!("0x{:016X}", value),
1111        PrimitiveType::F32 | PrimitiveType::F64 | PrimitiveType::Bool | PrimitiveType::Bytes => {
1112            format!("0x{:X}", value)
1113        }
1114    }
1115}
1116
1117// ── Helpers ───────────────────────────────────────────────────────────────────
1118
1119/// Returns the `@mid` attribute value, if present.
1120fn find_mid_attr(attrs: &[Attribute]) -> Option<&Literal> {
1121    attrs.iter().find(|a| a.name == "mid").map(|a| &a.value)
1122}
1123
1124/// Returns the `@cc` attribute value, if present.
1125fn find_cc_attr(attrs: &[Attribute]) -> Option<&Literal> {
1126    attrs.iter().find(|a| a.name == "cc").map(|a| &a.value)
1127}
1128
1129fn packet_item(item: &Item) -> Option<&MessageDef> {
1130    match item {
1131        Item::Command(m) | Item::Telemetry(m) => Some(m),
1132        _ => None,
1133    }
1134}
1135
1136fn packet_is_command(m: &MessageDef) -> bool {
1137    match m.kind {
1138        PacketKind::Command => true,
1139        PacketKind::Telemetry | PacketKind::Message => false,
1140    }
1141}
1142
1143fn literal_to_u64(lit: &Literal) -> Option<u64> {
1144    match lit {
1145        Literal::Hex(n) => Some(*n),
1146        Literal::Int(n) if *n >= 0 => Some(*n as u64),
1147        _ => None,
1148    }
1149}
1150
1151fn resolve_literal_to_u64(lit: &Literal, constants: &ConstContext<'_>) -> Option<u64> {
1152    resolve_literal_to_u64_inner(lit, constants, &mut Vec::new())
1153}
1154
1155fn resolve_literal_to_u64_inner(
1156    lit: &Literal,
1157    constants: &ConstContext<'_>,
1158    seen: &mut Vec<Vec<String>>,
1159) -> Option<u64> {
1160    match lit {
1161        Literal::Ident(segments) => resolve_ident_to_u64_inner(segments, constants, seen),
1162        other => literal_to_u64(other),
1163    }
1164}
1165
1166fn resolve_ident_to_u64(segments: &[String], constants: &ConstContext<'_>) -> Option<u64> {
1167    resolve_ident_to_u64_inner(segments, constants, &mut Vec::new())
1168}
1169
1170fn resolve_ident_to_u64_inner(
1171    segments: &[String],
1172    constants: &ConstContext<'_>,
1173    seen: &mut Vec<Vec<String>>,
1174) -> Option<u64> {
1175    if seen.iter().any(|s| s == segments) {
1176        return None;
1177    }
1178    if let Some(c) = constants.local_defs.get(segments) {
1179        seen.push(segments.to_vec());
1180        let resolved = resolve_literal_to_u64_inner(&c.value, constants, seen);
1181        seen.pop();
1182        return resolved;
1183    }
1184    constants.imported_values.get(segments).copied()
1185}
1186
1187fn type_expr_display(ty: &TypeExpr) -> String {
1188    let mut out = base_type_display(&ty.base);
1189    match &ty.array {
1190        None => {}
1191        Some(ArraySuffix::Dynamic) => out.push_str("[]"),
1192        Some(ArraySuffix::Fixed(n)) => out.push_str(&format!("[{n}]")),
1193        Some(ArraySuffix::Bounded(n)) => out.push_str(&format!("[<={n}]")),
1194    }
1195    out
1196}
1197
1198fn base_type_display(base: &BaseType) -> String {
1199    match base {
1200        BaseType::String => "string".to_string(),
1201        BaseType::Primitive(p) => primitive_name(*p).to_string(),
1202        BaseType::Ref(segments) => segments.join("::"),
1203    }
1204}
1205
1206fn primitive_name(p: PrimitiveType) -> &'static str {
1207    match p {
1208        PrimitiveType::F32 => "f32",
1209        PrimitiveType::F64 => "f64",
1210        PrimitiveType::I8 => "i8",
1211        PrimitiveType::I16 => "i16",
1212        PrimitiveType::I32 => "i32",
1213        PrimitiveType::I64 => "i64",
1214        PrimitiveType::U8 => "u8",
1215        PrimitiveType::U16 => "u16",
1216        PrimitiveType::U32 => "u32",
1217        PrimitiveType::U64 => "u64",
1218        PrimitiveType::Bool => "bool",
1219        PrimitiveType::Bytes => "bytes",
1220    }
1221}
1222
1223fn emit_doc_lines(out: &mut String, doc: &[String]) {
1224    for line in doc {
1225        if line.is_empty() {
1226            out.push_str("///\n");
1227        } else {
1228            out.push_str(&format!("/// {line}\n"));
1229        }
1230    }
1231}
1232
1233fn emit_indented_doc_lines(out: &mut String, doc: &[String]) {
1234    for line in doc {
1235        if line.is_empty() {
1236            out.push_str("    ///\n");
1237        } else {
1238            out.push_str(&format!("    /// {line}\n"));
1239        }
1240    }
1241}
1242
1243/// Format a MID literal for a `#define` line.
1244fn literal_mid_str(lit: &Literal, constants: &ConstContext<'_>) -> String {
1245    match lit {
1246        Literal::Hex(n) => format!("0x{:04X}U", n),
1247        Literal::Int(n) => format!("{}U", n),
1248        Literal::Ident(segs) if constants.is_local_bare_ident(segs) => segs.join("::"),
1249        Literal::Ident(segs) => resolve_ident_to_u64(segs, constants)
1250            .map(|value| format!("0x{:04X}U", value))
1251            .unwrap_or_else(|| segs.join("::")),
1252        other => literal_str(other),
1253    }
1254}
1255
1256/// Format a command-code literal for a `#define` line.
1257fn literal_cc_str(lit: &Literal, constants: &ConstContext<'_>) -> String {
1258    match lit {
1259        Literal::Hex(n) => format!("0x{:X}U", n),
1260        Literal::Int(n) => format!("{}U", n),
1261        Literal::Ident(segs) if constants.is_local_bare_ident(segs) => segs.join("::"),
1262        Literal::Ident(segs) => resolve_ident_to_u64(segs, constants)
1263            .map(|value| format!("{}U", value))
1264            .unwrap_or_else(|| segs.join("::")),
1265        other => literal_str(other),
1266    }
1267}
1268
1269fn literal_str(lit: &Literal) -> String {
1270    match lit {
1271        Literal::Float(f) => {
1272            let s = format!("{}", f);
1273            if s.contains('.') || s.contains('e') {
1274                s
1275            } else {
1276                format!("{}.0", s)
1277            }
1278        }
1279        Literal::Int(n) => n.to_string(),
1280        Literal::Hex(n) => format!("0x{:X}U", n),
1281        Literal::Bool(b) => {
1282            if *b {
1283                "1".to_string()
1284            } else {
1285                "0".to_string()
1286            }
1287        }
1288        Literal::Str(s) => format!("{:?}", s),
1289        Literal::Ident(segments) => segments.join("::"),
1290    }
1291}
1292
1293fn typed_literal_str(lit: &Literal, ty: &TypeExpr) -> String {
1294    match (lit, &ty.base) {
1295        (Literal::Hex(n), BaseType::Primitive(p)) => c_hex_str(*n, *p),
1296        _ => literal_str(lit),
1297    }
1298}
1299
1300fn c_hex_str(value: u64, ty: PrimitiveType) -> String {
1301    match ty {
1302        PrimitiveType::U8 | PrimitiveType::I8 => format!("0x{:02X}U", value),
1303        PrimitiveType::U16 | PrimitiveType::I16 => format!("0x{:04X}U", value),
1304        PrimitiveType::U32 | PrimitiveType::I32 => format!("0x{:08X}U", value),
1305        PrimitiveType::U64 | PrimitiveType::I64 => format!("0x{:016X}U", value),
1306        PrimitiveType::F32 | PrimitiveType::F64 | PrimitiveType::Bool | PrimitiveType::Bytes => {
1307            format!("0x{:X}U", value)
1308        }
1309    }
1310}
1311
1312fn non_fixed_type_str(ty: &TypeExpr, namespace: &[String]) -> String {
1313    if ty.base == BaseType::String {
1314        return match &ty.array {
1315            None | Some(ArraySuffix::Dynamic) => "const char*".to_string(),
1316            Some(ArraySuffix::Fixed(_)) => unreachable!("handled by emit_c_field"),
1317            Some(ArraySuffix::Bounded(n)) => format!("char[{}]", n),
1318        };
1319    }
1320
1321    let base = base_type_str(&ty.base, namespace);
1322    match &ty.array {
1323        None => base,
1324        Some(ArraySuffix::Fixed(_)) => unreachable!("handled by caller"),
1325        Some(ArraySuffix::Dynamic) => format!("CFE_Span_t /* {} */", base),
1326        Some(ArraySuffix::Bounded(n)) => format!("CFE_Span_t /* {} max {} */", base, n),
1327    }
1328}
1329
1330fn base_type_str(base: &BaseType, namespace: &[String]) -> String {
1331    match base {
1332        BaseType::String => "const char*".to_string(),
1333        BaseType::Primitive(p) => primitive_str(*p).to_string(),
1334        BaseType::Ref(segments) => c_ref_type_name(segments, namespace),
1335    }
1336}
1337
1338fn emit_c_field(out: &mut String, f: &synapse_parser::ast::FieldDef, namespace: &[String]) {
1339    emit_indented_doc_lines(out, &f.doc);
1340    match (&f.ty.base, &f.ty.array) {
1341        (BaseType::String, Some(ArraySuffix::Fixed(n) | ArraySuffix::Bounded(n))) => {
1342            out.push_str(&format!("    char {}[{}];\n", f.name, n));
1343        }
1344        (_, Some(ArraySuffix::Fixed(n))) => {
1345            out.push_str(&format!(
1346                "    {} {}[{}];\n",
1347                base_type_str(&f.ty.base, namespace),
1348                f.name,
1349                n
1350            ));
1351        }
1352        _ => {
1353            out.push_str(&format!(
1354                "    {} {};\n",
1355                non_fixed_type_str(&f.ty, namespace),
1356                f.name
1357            ));
1358        }
1359    }
1360}
1361
1362fn c_decl_type_name(name: &str, namespace: &[String]) -> String {
1363    let mut segments = namespace.to_vec();
1364    segments.push(name.to_string());
1365    format!("{}_t", segments.join("_"))
1366}
1367
1368fn c_enum_variant_prefix(name: &str, namespace: &[String]) -> String {
1369    let mut segments = namespace.to_vec();
1370    segments.push(name.to_string());
1371    segments
1372        .iter()
1373        .map(|segment| to_screaming_snake(segment))
1374        .collect::<Vec<_>>()
1375        .join("_")
1376}
1377
1378fn c_ref_type_name(segments: &[String], namespace: &[String]) -> String {
1379    let resolved = if segments.len() == 1 && !namespace.is_empty() {
1380        let mut resolved = namespace.to_vec();
1381        resolved.push(segments[0].clone());
1382        resolved
1383    } else {
1384        segments.to_vec()
1385    };
1386    if resolved.is_empty() {
1387        return "_t".to_string();
1388    }
1389    format!("{}_t", resolved.join("_"))
1390}
1391
1392fn import_c_header(path: &str) -> String {
1393    replace_extension(path, "h")
1394}
1395
1396fn import_rust_module(path: &str) -> String {
1397    let header = path.rsplit('/').next().unwrap_or(path);
1398    replace_extension(header, "")
1399}
1400
1401fn replace_extension(path: &str, ext: &str) -> String {
1402    match path.rsplit_once('.') {
1403        Some((stem, _)) if ext.is_empty() => stem.to_string(),
1404        Some((stem, _)) => format!("{stem}.{ext}"),
1405        None if ext.is_empty() => path.to_string(),
1406        None => format!("{path}.{ext}"),
1407    }
1408}
1409
1410fn primitive_str(p: PrimitiveType) -> &'static str {
1411    match p {
1412        PrimitiveType::F32 => "float",
1413        PrimitiveType::F64 => "double",
1414        PrimitiveType::I8 => "int8_t",
1415        PrimitiveType::I16 => "int16_t",
1416        PrimitiveType::I32 => "int32_t",
1417        PrimitiveType::I64 => "int64_t",
1418        PrimitiveType::U8 => "uint8_t",
1419        PrimitiveType::U16 => "uint16_t",
1420        PrimitiveType::U32 => "uint32_t",
1421        PrimitiveType::U64 => "uint64_t",
1422        PrimitiveType::Bool => "bool",
1423        PrimitiveType::Bytes => "uint8_t*",
1424    }
1425}
1426
1427/// Convert `PascalCase` → `PASCAL_CASE` (screaming snake case).
1428fn to_screaming_snake(name: &str) -> String {
1429    let mut out = String::new();
1430    for (i, ch) in name.chars().enumerate() {
1431        if ch.is_uppercase() && i > 0 {
1432            out.push('_');
1433        }
1434        out.push(ch.to_ascii_uppercase());
1435    }
1436    out
1437}
1438
1439// ── Tests ─────────────────────────────────────────────────────────────────────
1440
1441#[cfg(test)]
1442mod tests {
1443    use super::*;
1444    use synapse_parser::ast::parse;
1445
1446    fn codegen(src: &str) -> String {
1447        generate_c(&parse(src).unwrap())
1448    }
1449
1450    #[test]
1451    fn telemetry_with_hex_mid() {
1452        let out = codegen("@mid(0x0801)\ntelemetry NavTlm { x: f64  y: f64 }");
1453        assert!(out.starts_with("/* Generated by Synapse. Do not edit directly. */\n"));
1454        assert!(out.contains("#define NAV_TLM_MID  0x0801U"));
1455        assert!(out.contains("CFE_MSG_TelemetryHeader_t Header;"));
1456        assert!(out.contains("typedef struct {"));
1457        assert!(out.contains("} NavTlm_t;"));
1458        assert!(out.contains("    double x;"));
1459        assert!(out.contains("    double y;"));
1460    }
1461
1462    #[test]
1463    fn command_uses_declared_packet_kind() {
1464        let out = codegen("@mid(0x1881)\n@cc(1)\ncommand NavCmd { seq: u16 }");
1465        assert!(out.contains("#define NAV_CMD_MID  0x1881U"));
1466        assert!(out.contains("#define NAV_CMD_CC   1U"));
1467        assert!(out.contains("CFE_MSG_CommandHeader_t Header;"));
1468        assert!(out.contains("} NavCmd_t;"));
1469    }
1470
1471    #[test]
1472    fn command_uses_command_header() {
1473        let out = codegen("@mid(0x1880)\n@cc(2)\ncommand SetMode { mode: u8 }");
1474        assert!(out.contains("#define SET_MODE_MID  0x1880U"));
1475        assert!(out.contains("#define SET_MODE_CC   2U"));
1476        assert!(out.contains("CFE_MSG_CommandHeader_t Header;"));
1477        assert!(!out.contains("CFE_MSG_TelemetryHeader_t Header;"));
1478    }
1479
1480    #[test]
1481    fn telemetry_uses_telemetry_header() {
1482        let out = codegen("@mid(0x0801)\ntelemetry NavState { x: f64 }");
1483        assert!(out.contains("#define NAV_STATE_MID  0x0801U"));
1484        assert!(out.contains("CFE_MSG_TelemetryHeader_t Header;"));
1485        assert!(!out.contains("CFE_MSG_CommandHeader_t Header;"));
1486    }
1487
1488    #[test]
1489    fn table_is_plain_data_without_bus_header() {
1490        let out = codegen("table NavConfig { max_speed: f64  enabled: bool }");
1491        assert!(out.contains("} NavConfig_t;"));
1492        assert!(out.contains("    double max_speed;"));
1493        assert!(!out.contains("CFE_MSG_CommandHeader_t Header;"));
1494        assert!(!out.contains("CFE_MSG_TelemetryHeader_t Header;"));
1495    }
1496
1497    #[test]
1498    fn c_rejects_legacy_message() {
1499        let file = parse("message Bare { x: f32 }").unwrap();
1500        let err = try_generate_c(&file).unwrap_err();
1501        assert_eq!(
1502            err,
1503            CodegenError::LegacyMessageUnsupported {
1504                packet: "Bare".to_string(),
1505            }
1506        );
1507        assert_eq!(
1508            err.to_string(),
1509            "legacy message `Bare` is not supported by cFS codegen; use `command` or `telemetry`"
1510        );
1511    }
1512
1513    #[test]
1514    fn c_rejects_command_without_mid() {
1515        let file = parse("command SetMode { mode: u8 }").unwrap();
1516        let err = try_generate_c(&file).unwrap_err();
1517        assert_eq!(
1518            err,
1519            CodegenError::MissingMid {
1520                packet: "SetMode".to_string(),
1521            }
1522        );
1523        assert_eq!(
1524            err.to_string(),
1525            "packet `SetMode` is missing required `@mid(...)`"
1526        );
1527    }
1528
1529    #[test]
1530    fn c_rejects_command_without_cc() {
1531        let file = parse("@mid(0x1880)\ncommand SetMode { mode: u8 }").unwrap();
1532        let err = try_generate_c(&file).unwrap_err();
1533        assert_eq!(
1534            err,
1535            CodegenError::MissingCommandCode {
1536                packet: "SetMode".to_string(),
1537            }
1538        );
1539        assert_eq!(
1540            err.to_string(),
1541            "command `SetMode` is missing required `@cc(...)`"
1542        );
1543    }
1544
1545    #[test]
1546    fn c_rejects_cc_on_telemetry() {
1547        let file = parse("@mid(0x0801)\n@cc(1)\ntelemetry Status { x: f32 }").unwrap();
1548        let err = try_generate_c(&file).unwrap_err();
1549        assert_eq!(
1550            err,
1551            CodegenError::CommandCodeUnsupported {
1552                item: "Status".to_string(),
1553            }
1554        );
1555    }
1556
1557    #[test]
1558    fn c_rejects_cc_on_table() {
1559        let file = parse("@cc(1)\ntable Config { enabled: bool }").unwrap();
1560        let err = try_generate_c(&file).unwrap_err();
1561        assert_eq!(
1562            err,
1563            CodegenError::CommandCodeUnsupported {
1564                item: "Config".to_string(),
1565            }
1566        );
1567    }
1568
1569    #[test]
1570    fn c_rejects_mid_on_table() {
1571        let file = parse("@mid(0x0801)\ntable Config { enabled: bool }").unwrap();
1572        let err = try_generate_c(&file).unwrap_err();
1573        assert_eq!(
1574            err,
1575            CodegenError::MessageIdUnsupported {
1576                item: "Config".to_string(),
1577            }
1578        );
1579        assert_eq!(
1580            err.to_string(),
1581            "`@mid(...)` is only supported on command and telemetry packets, found on `Config`"
1582        );
1583    }
1584
1585    #[test]
1586    fn c_rejects_unresolved_symbolic_command_code() {
1587        let file = parse("@mid(0x1880)\n@cc(SET_MODE_CC)\ncommand SetMode { mode: u8 }").unwrap();
1588        let err = try_generate_c(&file).unwrap_err();
1589        assert_eq!(
1590            err,
1591            CodegenError::CommandCodeValueUnsupported {
1592                packet: "SetMode".to_string(),
1593            }
1594        );
1595    }
1596
1597    #[test]
1598    fn c_rejects_unresolved_symbolic_mid() {
1599        let file = parse("@mid(NAV_TLM_MID)\ntelemetry Status { x: f32 }").unwrap();
1600        let err = try_generate_c(&file).unwrap_err();
1601        assert_eq!(
1602            err,
1603            CodegenError::MessageIdValueUnsupported {
1604                packet: "Status".to_string(),
1605            }
1606        );
1607    }
1608
1609    #[test]
1610    fn c_resolves_local_symbolic_mid_and_command_code() {
1611        let out = codegen(
1612            "const SET_MODE_MID_VALUE: u16 = 0x1880\nconst SET_MODE_CODE: u16 = 1\n@mid(SET_MODE_MID_VALUE)\n@cc(SET_MODE_CODE)\ncommand SetMode { mode: u8 }",
1613        );
1614        assert!(out.contains("#define SET_MODE_MID  SET_MODE_MID_VALUE"));
1615        assert!(out.contains("#define SET_MODE_CC   SET_MODE_CODE"));
1616    }
1617
1618    #[test]
1619    fn c_resolves_imported_symbolic_mid_and_command_code() {
1620        let file = parse(
1621            "@mid(nav_app::SET_MODE_MID_VALUE)\n@cc(nav_app::SET_MODE_CODE)\ncommand SetMode { mode: u8 }",
1622        )
1623        .unwrap();
1624        let mut constants = ResolvedConstants::new();
1625        constants.insert(
1626            vec!["nav_app".to_string(), "SET_MODE_MID_VALUE".to_string()],
1627            0x1880,
1628        );
1629        constants.insert(vec!["nav_app".to_string(), "SET_MODE_CODE".to_string()], 2);
1630
1631        let out = try_generate_c_with_constants(&file, &constants).unwrap();
1632        assert!(out.contains("#define SET_MODE_MID  0x1880U"));
1633        assert!(out.contains("#define SET_MODE_CC   2U"));
1634    }
1635
1636    #[test]
1637    fn c_validates_local_symbolic_mid_range() {
1638        let file = parse(
1639            "const SET_MODE_MID_VALUE: u16 = 0x0801\n@mid(SET_MODE_MID_VALUE)\n@cc(1)\ncommand SetMode { mode: u8 }",
1640        )
1641        .unwrap();
1642        let err = try_generate_c(&file).unwrap_err();
1643        assert_eq!(
1644            err,
1645            CodegenError::MidRangeMismatch {
1646                packet: "SetMode".to_string(),
1647                mid: "SET_MODE_MID_VALUE".to_string(),
1648                expected: "command MID with bit 0x1000 set",
1649            }
1650        );
1651    }
1652
1653    #[test]
1654    fn c_detects_duplicate_local_symbolic_command_codes() {
1655        let file = parse(
1656            "const CMD_MID: u16 = 0x1880\nconst SET_CC: u16 = 1\n@mid(CMD_MID)\n@cc(SET_CC)\ncommand A { x: u8 }\n@mid(CMD_MID)\n@cc(SET_CC)\ncommand B { x: u8 }",
1657        )
1658        .unwrap();
1659        let err = try_generate_c(&file).unwrap_err();
1660        assert_eq!(
1661            err,
1662            CodegenError::DuplicateCommandCode {
1663                mid: "CMD_MID".to_string(),
1664                cc: "SET_CC".to_string(),
1665                first_packet: "A".to_string(),
1666                second_packet: "B".to_string(),
1667            }
1668        );
1669    }
1670
1671    #[test]
1672    fn c_rejects_duplicate_telemetry_mids() {
1673        let file =
1674            parse("@mid(0x0801)\ntelemetry A { x: u8 }\n@mid(0x0801)\ntelemetry B { x: u8 }")
1675                .unwrap();
1676        let err = try_generate_c(&file).unwrap_err();
1677        assert_eq!(
1678            err,
1679            CodegenError::DuplicateMid {
1680                mid: "0x0801U".to_string(),
1681                first_packet: "A".to_string(),
1682                second_packet: "B".to_string(),
1683            }
1684        );
1685        assert_eq!(
1686            err.to_string(),
1687            "duplicate MID `0x0801U` used by packets `A` and `B`"
1688        );
1689    }
1690
1691    #[test]
1692    fn c_allows_shared_command_mid_with_distinct_ccs() {
1693        let out = codegen(
1694            "@mid(0x1880)\n@cc(1)\ncommand A { x: u8 }\n@mid(0x1880)\n@cc(2)\ncommand B { x: u8 }",
1695        );
1696        assert!(out.contains("#define A_MID  0x1880U"));
1697        assert!(out.contains("#define B_MID  0x1880U"));
1698        assert!(out.contains("#define A_CC   1U"));
1699        assert!(out.contains("#define B_CC   2U"));
1700    }
1701
1702    #[test]
1703    fn c_rejects_duplicate_command_mid_cc_pairs() {
1704        let file = parse(
1705            "@mid(0x1880)\n@cc(1)\ncommand A { x: u8 }\n@mid(0x1880)\n@cc(1)\ncommand B { x: u8 }",
1706        )
1707        .unwrap();
1708        let err = try_generate_c(&file).unwrap_err();
1709        assert_eq!(
1710            err,
1711            CodegenError::DuplicateCommandCode {
1712                mid: "0x1880U".to_string(),
1713                cc: "1U".to_string(),
1714                first_packet: "A".to_string(),
1715                second_packet: "B".to_string(),
1716            }
1717        );
1718        assert_eq!(
1719            err.to_string(),
1720            "duplicate command MID/CC pair `0x1880U`/`1U` used by packets `A` and `B`"
1721        );
1722    }
1723
1724    #[test]
1725    fn c_rejects_command_mid_without_command_bit() {
1726        let file = parse("@mid(0x0801)\n@cc(1)\ncommand SetMode { mode: u8 }").unwrap();
1727        let err = try_generate_c(&file).unwrap_err();
1728        assert_eq!(
1729            err,
1730            CodegenError::MidRangeMismatch {
1731                packet: "SetMode".to_string(),
1732                mid: "0x0801U".to_string(),
1733                expected: "command MID with bit 0x1000 set",
1734            }
1735        );
1736        assert_eq!(
1737            err.to_string(),
1738            "packet `SetMode` has MID `0x0801U`, expected command MID with bit 0x1000 set"
1739        );
1740    }
1741
1742    #[test]
1743    fn c_rejects_telemetry_mid_with_command_bit() {
1744        let file = parse("@mid(0x1880)\ntelemetry Status { x: f32 }").unwrap();
1745        let err = try_generate_c(&file).unwrap_err();
1746        assert_eq!(
1747            err,
1748            CodegenError::MidRangeMismatch {
1749                packet: "Status".to_string(),
1750                mid: "0x1880U".to_string(),
1751                expected: "telemetry MID with bit 0x1000 clear",
1752            }
1753        );
1754    }
1755
1756    #[test]
1757    fn c_rejects_optional_fields() {
1758        let file = parse("@mid(0x0801)\ntelemetry Status { error_code?: u32 }").unwrap();
1759        let err = try_generate_c(&file).unwrap_err();
1760        assert_eq!(
1761            err,
1762            CodegenError::OptionalFieldUnsupported {
1763                container: "Status".to_string(),
1764                field: "error_code".to_string(),
1765            }
1766        );
1767        assert_eq!(
1768            err.to_string(),
1769            "optional field `Status.error_code` is not supported by cFS codegen yet"
1770        );
1771    }
1772
1773    #[test]
1774    fn c_rejects_default_values() {
1775        let file = parse("table Config { exposure_us: u32 = 10000 }").unwrap();
1776        let err = try_generate_c(&file).unwrap_err();
1777        assert_eq!(
1778            err,
1779            CodegenError::DefaultValueUnsupported {
1780                container: "Config".to_string(),
1781                field: "exposure_us".to_string(),
1782            }
1783        );
1784        assert_eq!(
1785            err.to_string(),
1786            "default value for field `Config.exposure_us` is not supported by cFS codegen yet"
1787        );
1788    }
1789
1790    #[test]
1791    fn c_rejects_enum_fields() {
1792        let file = parse(
1793            "enum CameraMode { Idle = 0 Streaming = 1 }\n@mid(0x0801)\ntelemetry Status { mode: CameraMode }",
1794        )
1795        .unwrap();
1796        let err = try_generate_c(&file).unwrap_err();
1797        assert_eq!(
1798            err,
1799            CodegenError::EnumFieldUnsupported {
1800                container: "Status".to_string(),
1801                field: "mode".to_string(),
1802                ty: "CameraMode".to_string(),
1803            }
1804        );
1805        assert_eq!(
1806            err.to_string(),
1807            "enum field `Status.mode` with type `CameraMode` needs an explicit integer representation for cFS codegen"
1808        );
1809    }
1810
1811    #[test]
1812    fn c_emits_represented_enum_fields() {
1813        let file = parse(
1814            "enum u8 CameraMode { Idle = 0 Streaming = 1 }\n@mid(0x0801)\ntelemetry Status { mode: CameraMode }",
1815        )
1816        .unwrap();
1817        let out = try_generate_c(&file).unwrap();
1818        assert!(out.contains("typedef uint8_t CameraMode_t;"));
1819        assert!(out.contains("#define CAMERA_MODE_IDLE  ((CameraMode_t)0)"));
1820        assert!(out.contains("#define CAMERA_MODE_STREAMING  ((CameraMode_t)1)"));
1821        assert!(out.contains("    CameraMode_t mode;"));
1822    }
1823
1824    #[test]
1825    fn c_namespaces_represented_enum_variant_constants() {
1826        let file = parse(
1827            "namespace camera_app\nenum u8 CameraMode { Idle = 0 Streaming = 1 }\n@mid(0x0801)\ntelemetry Status { mode: CameraMode }",
1828        )
1829        .unwrap();
1830        let out = try_generate_c(&file).unwrap();
1831        assert!(out.contains("typedef uint8_t camera_app_CameraMode_t;"));
1832        assert!(out.contains("#define CAMERA_APP_CAMERA_MODE_IDLE  ((camera_app_CameraMode_t)0)"));
1833        assert!(
1834            out.contains("#define CAMERA_APP_CAMERA_MODE_STREAMING  ((camera_app_CameraMode_t)1)")
1835        );
1836        assert!(out.contains("    camera_app_CameraMode_t mode;"));
1837    }
1838
1839    #[test]
1840    fn c_rejects_represented_enum_missing_value() {
1841        let file = parse("enum u8 CameraMode { Idle Streaming = 1 }").unwrap();
1842        let err = try_generate_c(&file).unwrap_err();
1843        assert_eq!(
1844            err,
1845            CodegenError::EnumVariantValueRequired {
1846                enum_name: "CameraMode".to_string(),
1847                variant: "Idle".to_string(),
1848            }
1849        );
1850    }
1851
1852    #[test]
1853    fn c_rejects_non_integer_enum_repr() {
1854        let file = parse("enum bool CameraMode { Idle = 0 Streaming = 1 }").unwrap();
1855        let err = try_generate_c(&file).unwrap_err();
1856        assert_eq!(
1857            err,
1858            CodegenError::EnumRepresentationUnsupported {
1859                enum_name: "CameraMode".to_string(),
1860                repr: "bool".to_string(),
1861            }
1862        );
1863    }
1864
1865    #[test]
1866    fn c_rejects_represented_enum_out_of_range() {
1867        let file = parse("enum u8 CameraMode { TooLarge = 256 }").unwrap();
1868        let err = try_generate_c(&file).unwrap_err();
1869        assert_eq!(
1870            err,
1871            CodegenError::EnumVariantValueOutOfRange {
1872                enum_name: "CameraMode".to_string(),
1873                variant: "TooLarge".to_string(),
1874                value: 256,
1875                repr: "u8".to_string(),
1876            }
1877        );
1878    }
1879
1880    #[test]
1881    fn c_rejects_dynamic_arrays() {
1882        let file = parse("@mid(0x0801)\ntelemetry Samples { values: f32[] }").unwrap();
1883        let err = try_generate_c(&file).unwrap_err();
1884        assert_eq!(
1885            err,
1886            CodegenError::DynamicArrayUnsupported {
1887                container: "Samples".to_string(),
1888                field: "values".to_string(),
1889                ty: "f32[]".to_string(),
1890            }
1891        );
1892        assert_eq!(
1893            err.to_string(),
1894            "dynamic array field `Samples.values` with type `f32[]` is not supported by cFS codegen yet"
1895        );
1896    }
1897
1898    #[test]
1899    fn c_rejects_non_string_bounded_arrays() {
1900        let file = parse("table Buffer { bytes: u8[<=256] }").unwrap();
1901        let err = try_generate_c(&file).unwrap_err();
1902        assert_eq!(
1903            err,
1904            CodegenError::BoundedArrayUnsupported {
1905                container: "Buffer".to_string(),
1906                field: "bytes".to_string(),
1907                ty: "u8[<=256]".to_string(),
1908            }
1909        );
1910        assert_eq!(
1911            err.to_string(),
1912            "bounded array field `Buffer.bytes` with type `u8[<=256]` is not supported by cFS codegen yet"
1913        );
1914    }
1915
1916    #[test]
1917    fn const_emits_define() {
1918        let out = codegen("const NAV_TLM_MID: u16 = 0x0801");
1919        assert!(out.contains("#define NAV_TLM_MID  0x0801U"));
1920    }
1921
1922    #[test]
1923    fn fixed_array_field() {
1924        let out = codegen("@mid(0x0802)\ntelemetry Imu { covariance: f64[9] }");
1925        assert!(out.contains("    double covariance[9];"));
1926    }
1927
1928    #[test]
1929    fn c_refs_use_declared_typedef_names() {
1930        let out = codegen("struct Point { x: f64 }\n@mid(0x0801)\ntelemetry Pose { point: Point }");
1931        assert!(out.contains("} Point_t;"));
1932        assert!(out.contains("    Point_t point;"));
1933    }
1934
1935    #[test]
1936    fn c_qualified_refs_use_declared_typedef_names() {
1937        let out = codegen("@mid(0x0801)\ntelemetry Stamped { header: std_msgs::Header }");
1938        assert!(out.contains("    std_msgs_Header_t header;"));
1939    }
1940
1941    #[test]
1942    fn c_bounded_string_uses_inline_storage() {
1943        let out = codegen("struct Label { name: string[<=64] }");
1944        assert!(out.contains("    char name[64];"));
1945    }
1946
1947    #[test]
1948    fn c_rejects_unbounded_strings() {
1949        let file = parse("struct Label { name: string }").unwrap();
1950        let err = try_generate_c(&file).unwrap_err();
1951        assert_eq!(
1952            err,
1953            CodegenError::UnboundedStringUnsupported {
1954                container: "Label".to_string(),
1955                field: "name".to_string(),
1956            }
1957        );
1958        assert_eq!(
1959            err.to_string(),
1960            "unbounded string field `Label.name` is not supported by cFS codegen; use `string[<=N]` or `string[N]`"
1961        );
1962    }
1963
1964    #[test]
1965    fn c_imports_emit_header_includes() {
1966        let out = codegen(r#"import "std_msgs.syn""#);
1967        assert!(out.contains("#include \"std_msgs.h\""));
1968    }
1969
1970    #[test]
1971    fn c_doc_comments_emit_for_declarations_and_fields() {
1972        let out = codegen("/// A point\nstruct Point {\n/// X axis\nx: f64\n}");
1973        assert!(out.contains("/// A point\ntypedef struct {"));
1974        assert!(out.contains("    /// X axis\n    double x;"));
1975    }
1976
1977    // ── Rust codegen ─────────────────────────────────────────
1978
1979    fn rust_codegen(src: &str) -> String {
1980        generate_rust(&parse(src).unwrap(), &RustOptions::default())
1981    }
1982
1983    #[test]
1984    fn rust_tlm_struct() {
1985        let out = rust_codegen("@mid(0x0801)\ntelemetry NavTlm { x: f64  y: f64 }");
1986        assert!(out.starts_with("// Generated by Synapse. Do not edit directly.\n"));
1987        assert!(out.contains("pub const NAV_TLM_MID: u16 = 0x0801;"));
1988        assert!(out.contains("#[repr(C)]"));
1989        assert!(out.contains("pub struct NavTlm {"));
1990        assert!(out.contains("    pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
1991        assert!(out.contains("    pub x: f64,"));
1992        assert!(out.contains("    pub y: f64,"));
1993    }
1994
1995    #[test]
1996    fn rust_cmd_struct() {
1997        let out = rust_codegen("@mid(0x1880)\n@cc(1)\ncommand NavCmd { seq: u16 }");
1998        assert!(out.contains("pub const NAV_CMD_MID: u16 = 0x1880;"));
1999        assert!(out.contains("pub const NAV_CMD_CC: u16 = 1;"));
2000        assert!(out.contains("    pub cfs_header: cfs_sys::CFE_MSG_CommandHeader_t,"));
2001    }
2002
2003    #[test]
2004    fn rust_command_uses_command_header() {
2005        let out = rust_codegen("@mid(0x1881)\n@cc(2)\ncommand SetMode { mode: u8 }");
2006        assert!(out.contains("pub const SET_MODE_MID: u16 = 0x1881;"));
2007        assert!(out.contains("pub const SET_MODE_CC: u16 = 2;"));
2008        assert!(out.contains("    pub cfs_header: cfs_sys::CFE_MSG_CommandHeader_t,"));
2009        assert!(!out.contains("CFE_MSG_TelemetryHeader_t"));
2010    }
2011
2012    #[test]
2013    fn rust_resolves_imported_symbolic_mid_and_command_code() {
2014        let file = parse(
2015            "@mid(nav_app::SET_MODE_MID_VALUE)\n@cc(nav_app::SET_MODE_CODE)\ncommand SetMode { mode: u8 }",
2016        )
2017        .unwrap();
2018        let mut constants = ResolvedConstants::new();
2019        constants.insert(
2020            vec!["nav_app".to_string(), "SET_MODE_MID_VALUE".to_string()],
2021            0x1880,
2022        );
2023        constants.insert(vec!["nav_app".to_string(), "SET_MODE_CODE".to_string()], 2);
2024
2025        let out =
2026            try_generate_rust_with_constants(&file, &RustOptions::default(), &constants).unwrap();
2027        assert!(out.contains("pub const SET_MODE_MID: u16 = 0x1880;"));
2028        assert!(out.contains("pub const SET_MODE_CC: u16 = 2;"));
2029    }
2030
2031    #[test]
2032    fn rust_telemetry_uses_telemetry_header() {
2033        let out = rust_codegen("@mid(0x0801)\ntelemetry NavState { x: f64 }");
2034        assert!(out.contains("pub const NAV_STATE_MID: u16 = 0x0801;"));
2035        assert!(out.contains("    pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
2036        assert!(!out.contains("CFE_MSG_CommandHeader_t"));
2037    }
2038
2039    #[test]
2040    fn rust_table_is_plain_data_without_bus_header() {
2041        let out = rust_codegen("table NavConfig { max_speed: f64  enabled: bool }");
2042        assert!(out.contains("pub struct NavConfig {"));
2043        assert!(out.contains("    pub max_speed: f64,"));
2044        assert!(!out.contains("cfs_header"));
2045    }
2046
2047    #[test]
2048    fn rust_fixed_array() {
2049        let out = rust_codegen("@mid(0x0802)\ntelemetry Imu { covariance: f64[9] }");
2050        assert!(out.contains("    pub covariance: [f64; 9],"));
2051    }
2052
2053    #[test]
2054    fn rust_custom_module() {
2055        let opts = RustOptions {
2056            cfs_module: "my_cfs",
2057            ..Default::default()
2058        };
2059        let out = generate_rust(
2060            &parse("@mid(0x0801)\ntelemetry T { x: f32 }").unwrap(),
2061            &opts,
2062        );
2063        assert!(out.contains("my_cfs::CFE_MSG_TelemetryHeader_t"));
2064    }
2065
2066    #[test]
2067    fn rust_bare_module() {
2068        let opts = RustOptions {
2069            cfs_module: "",
2070            ..Default::default()
2071        };
2072        let out = generate_rust(
2073            &parse("@mid(0x0801)\ntelemetry T { x: f32 }").unwrap(),
2074            &opts,
2075        );
2076        assert!(out.contains("    pub cfs_header: CFE_MSG_TelemetryHeader_t,"));
2077        assert!(!out.contains("::CFE_MSG_TelemetryHeader_t"));
2078    }
2079
2080    #[test]
2081    fn rust_message_can_have_payload_header_field() {
2082        let out = rust_codegen("@mid(0x0801)\ntelemetry Stamped { header: std_msgs::Header }");
2083        assert!(out.contains("    pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
2084        assert!(out.contains("    pub header: std_msgs::Header,"));
2085    }
2086
2087    #[test]
2088    fn rust_rejects_legacy_message() {
2089        let file = parse("@mid(0x0801)\nmessage Bare { x: f32 }").unwrap();
2090        let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
2091        assert_eq!(
2092            err,
2093            CodegenError::LegacyMessageUnsupported {
2094                packet: "Bare".to_string(),
2095            }
2096        );
2097    }
2098
2099    #[test]
2100    fn rust_rejects_telemetry_without_mid() {
2101        let file = parse("telemetry Status { x: f32 }").unwrap();
2102        let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
2103        assert_eq!(
2104            err,
2105            CodegenError::MissingMid {
2106                packet: "Status".to_string(),
2107            }
2108        );
2109    }
2110
2111    #[test]
2112    fn rust_rejects_mid_range_mismatch() {
2113        let file = parse("@mid(0x1880)\ntelemetry Status { x: f32 }").unwrap();
2114        let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
2115        assert_eq!(
2116            err,
2117            CodegenError::MidRangeMismatch {
2118                packet: "Status".to_string(),
2119                mid: "0x1880U".to_string(),
2120                expected: "telemetry MID with bit 0x1000 clear",
2121            }
2122        );
2123    }
2124
2125    #[test]
2126    fn rust_rejects_optional_fields() {
2127        let file = parse("struct Status { error_code?: u32 }").unwrap();
2128        let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
2129        assert_eq!(
2130            err,
2131            CodegenError::OptionalFieldUnsupported {
2132                container: "Status".to_string(),
2133                field: "error_code".to_string(),
2134            }
2135        );
2136    }
2137
2138    #[test]
2139    fn rust_rejects_default_values() {
2140        let file = parse("struct Config { gain: f32 = 1.0 }").unwrap();
2141        let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
2142        assert_eq!(
2143            err,
2144            CodegenError::DefaultValueUnsupported {
2145                container: "Config".to_string(),
2146                field: "gain".to_string(),
2147            }
2148        );
2149    }
2150
2151    #[test]
2152    fn rust_rejects_enum_fields() {
2153        let file = parse("enum CameraMode { Idle Streaming }\nstruct Status { mode: CameraMode }")
2154            .unwrap();
2155        let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
2156        assert_eq!(
2157            err,
2158            CodegenError::EnumFieldUnsupported {
2159                container: "Status".to_string(),
2160                field: "mode".to_string(),
2161                ty: "CameraMode".to_string(),
2162            }
2163        );
2164    }
2165
2166    #[test]
2167    fn rust_emits_represented_enum_fields() {
2168        let file = parse(
2169            "enum u8 CameraMode { Idle = 0 Streaming = 1 }\nstruct Status { mode: CameraMode }",
2170        )
2171        .unwrap();
2172        let out = try_generate_rust(&file, &RustOptions::default()).unwrap();
2173        assert!(out.contains("pub type CameraMode = u8;"));
2174        assert!(out.contains("pub const CAMERA_MODE_IDLE: CameraMode = 0;"));
2175        assert!(out.contains("pub const CAMERA_MODE_STREAMING: CameraMode = 1;"));
2176        assert!(out.contains("    pub mode: CameraMode,"));
2177    }
2178
2179    #[test]
2180    fn rust_rejects_dynamic_arrays() {
2181        let file = parse("struct Samples { values: Point[] }").unwrap();
2182        let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
2183        assert_eq!(
2184            err,
2185            CodegenError::DynamicArrayUnsupported {
2186                container: "Samples".to_string(),
2187                field: "values".to_string(),
2188                ty: "Point[]".to_string(),
2189            }
2190        );
2191    }
2192
2193    #[test]
2194    fn rust_rejects_non_string_bounded_arrays() {
2195        let file = parse("struct Buffer { bytes: bytes[<=256] }").unwrap();
2196        let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
2197        assert_eq!(
2198            err,
2199            CodegenError::BoundedArrayUnsupported {
2200                container: "Buffer".to_string(),
2201                field: "bytes".to_string(),
2202                ty: "bytes[<=256]".to_string(),
2203            }
2204        );
2205    }
2206
2207    #[test]
2208    fn rust_const_uses_declared_type() {
2209        let out = rust_codegen(
2210            "const PI: f64 = 3.14\nconst ENABLED: bool = true\nconst NAV_TLM_MID: u16 = 0x0801",
2211        );
2212        assert!(out.contains("pub const PI: f64 = 3.14;"));
2213        assert!(out.contains("pub const ENABLED: bool = true;"));
2214        assert!(out.contains("pub const NAV_TLM_MID: u16 = 0x0801;"));
2215    }
2216
2217    #[test]
2218    fn rust_bounded_string_uses_inline_storage() {
2219        let out = rust_codegen("struct Label { name: string[<=64] }");
2220        assert!(out.contains("    pub name: [u8; 64],"));
2221    }
2222
2223    #[test]
2224    fn rust_rejects_unbounded_strings() {
2225        let file = parse("struct Label { name: string }").unwrap();
2226        let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
2227        assert_eq!(
2228            err,
2229            CodegenError::UnboundedStringUnsupported {
2230                container: "Label".to_string(),
2231                field: "name".to_string(),
2232            }
2233        );
2234    }
2235
2236    #[test]
2237    fn rust_imports_emit_crate_uses() {
2238        let out = rust_codegen(r#"import "std_msgs.syn""#);
2239        assert!(out.contains("use crate::std_msgs;"));
2240    }
2241
2242    #[test]
2243    fn rust_doc_comments_emit_for_declarations_and_fields() {
2244        let out = rust_codegen("/// A point\nstruct Point {\n/// X axis\nx: f64\n}");
2245        assert!(out.contains("/// A point\n#[repr(C)]\npub struct Point {"));
2246        assert!(out.contains("    /// X axis\n    pub x: f64,"));
2247    }
2248
2249    #[test]
2250    fn screaming_snake_conversion() {
2251        assert_eq!(to_screaming_snake("NavTelemetry"), "NAV_TELEMETRY");
2252        assert_eq!(to_screaming_snake("PoseStamped"), "POSE_STAMPED");
2253        assert_eq!(to_screaming_snake("Foo"), "FOO");
2254        assert_eq!(
2255            c_enum_variant_prefix("SensorMode", &["demo_msgs".to_string()]),
2256            "DEMO_MSGS_SENSOR_MODE"
2257        );
2258    }
2259}