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