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