Skip to main content

synapse_codegen_cfs/
lib.rs

1use synapse_parser::ast::{
2    ArraySuffix, Attribute, BaseType, ConstDecl, Item, Literal, MessageDef, PrimitiveType,
3    PacketKind, StructDef, SynFile, TypeExpr,
4};
5
6// ── Public API ────────────────────────────────────────────────────────────────
7
8/// Preamble included at the top of generated cFS C headers.
9pub const PREAMBLE: &str = "\
10#pragma once
11#include \"cfe.h\"
12
13";
14
15/// Options for Rust cFS binding generation.
16pub struct RustOptions<'a> {
17    /// Module path prefix for the cFS header types.
18    /// e.g. `"cfs"` → `cfs::TelemetryHeader`, `"cfe_sys"` → `cfe_sys::TelemetryHeader`.
19    /// Set to `""` to use bare type names.
20    pub cfs_module: &'a str,
21    /// Rust type name for telemetry message headers. Default: `"TelemetryHeader"`.
22    pub tlm_header: &'a str,
23    /// Rust type name for command message headers. Default: `"CommandHeader"`.
24    pub cmd_header: &'a str,
25}
26
27impl Default for RustOptions<'_> {
28    fn default() -> Self {
29        RustOptions {
30            cfs_module: "cfs_sys",
31            tlm_header: "CFE_MSG_TelemetryHeader_t",
32            cmd_header: "CFE_MSG_CommandHeader_t",
33        }
34    }
35}
36
37/// Generate a NASA cFS C header (`*_msg.h` + MID `#define`s) from a parsed Synapse file.
38pub fn generate_c(file: &SynFile) -> String {
39    let mut out = String::from(PREAMBLE);
40    emit_c_imports(file, &mut out);
41    emit_items(file, &mut out);
42    out
43}
44
45/// Generate `#[repr(C)]` Rust structs compatible with NASA cFS bindings.
46///
47/// `command` and `telemetry` packets become structs with the cFS header as the
48/// first field, matching the C ABI layout. `struct` and `table` items remain
49/// plain data structs. MID constants are emitted as `pub const`.
50pub fn generate_rust(file: &SynFile, opts: &RustOptions) -> String {
51    let mut out = String::new();
52    emit_rust_imports(file, &mut out);
53    emit_rust_items(file, opts, &mut out);
54    out
55}
56
57// ── Item emission ─────────────────────────────────────────────────────────────
58
59fn emit_c_imports(file: &SynFile, out: &mut String) {
60    let mut emitted = false;
61    for item in &file.items {
62        if let Item::Import(import) = item {
63            out.push_str(&format!("#include \"{}\"\n", import_c_header(&import.path)));
64            emitted = true;
65        }
66    }
67    if emitted {
68        out.push('\n');
69    }
70}
71
72fn emit_rust_imports(file: &SynFile, out: &mut String) {
73    let mut emitted = false;
74    for item in &file.items {
75        if let Item::Import(import) = item {
76            out.push_str(&format!("use crate::{};\n", import_rust_module(&import.path)));
77            emitted = true;
78        }
79    }
80    if emitted {
81        out.push('\n');
82    }
83}
84
85fn emit_items(file: &SynFile, out: &mut String) {
86    // First pass: emit #define MID lines for Software Bus packets with @mid
87    let mut has_mids = false;
88    for item in &file.items {
89        if let Some(m) = packet_item(item) {
90            if let Some(mid) = find_mid_attr(&m.attrs) {
91                if !has_mids {
92                    out.push_str("/* Message IDs */\n");
93                    has_mids = true;
94                }
95                let define_name = to_screaming_snake(&m.name);
96                let mid_str = literal_mid_str(mid);
97                out.push_str(&format!("#define {}_MID  {}\n", define_name, mid_str));
98            }
99        }
100    }
101    if has_mids { out.push('\n'); }
102
103    // Second pass: emit const, struct, and message types
104    let mut namespace = Vec::new();
105    for item in &file.items {
106        match item {
107            Item::Namespace(ns) => namespace = ns.name.clone(),
108            Item::Import(_) | Item::Enum(_) => {}
109            Item::Const(c)   => emit_const(out, c),
110            Item::Struct(s) | Item::Table(s) => emit_struct(out, s, &namespace),
111            Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => emit_message(out, m, &namespace),
112        }
113    }
114}
115
116// ── Const ─────────────────────────────────────────────────────────────────────
117
118fn emit_const(out: &mut String, c: &ConstDecl) {
119    let val = literal_str(&c.value);
120    out.push_str(&format!("#define {}  {}\n\n", c.name, val));
121}
122
123// ── Struct (plain supporting type, no cFS header) ─────────────────────────────
124
125fn emit_struct(out: &mut String, s: &StructDef, namespace: &[String]) {
126    for line in &s.doc {
127        if line.is_empty() { out.push_str("///\n"); } else { out.push_str(&format!("/// {line}\n")); }
128    }
129    out.push_str("typedef struct {\n");
130    for f in &s.fields {
131        emit_c_field(out, f, namespace);
132    }
133    out.push_str(&format!("}} {};\n\n", c_decl_type_name(&s.name, namespace)));
134}
135
136// ── Message ───────────────────────────────────────────────────────────────────
137
138fn emit_message(out: &mut String, m: &MessageDef, namespace: &[String]) {
139    let header_type = if packet_is_command(m) {
140        "CFE_MSG_CommandHeader_t"
141    } else {
142        "CFE_MSG_TelemetryHeader_t"
143    };
144
145    for line in &m.doc {
146        if line.is_empty() {
147            out.push_str("///\n");
148        } else {
149            out.push_str(&format!("/// {line}\n"));
150        }
151    }
152
153    out.push_str(&format!("typedef struct {{\n"));
154    out.push_str(&format!("    {} Header;\n", header_type));
155    for f in &m.fields {
156        emit_c_field(out, f, namespace);
157    }
158    out.push_str(&format!("}} {};\n\n", c_decl_type_name(&m.name, namespace)));
159}
160
161// ── Rust emission ─────────────────────────────────────────────────────────────
162
163fn emit_rust_items(file: &SynFile, opts: &RustOptions, out: &mut String) {
164    // First pass: MID consts for Software Bus packets with @mid
165    let mut has_mids = false;
166    for item in &file.items {
167        if let Some(m) = packet_item(item) {
168            if let Some(mid) = find_mid_attr(&m.attrs) {
169                if !has_mids {
170                    out.push_str("// Message IDs\n");
171                    has_mids = true;
172                }
173                let const_name = format!("{}_MID", to_screaming_snake(&m.name));
174                let val = rust_mid_str(mid);
175                out.push_str(&format!("pub const {}: u16 = {};\n", const_name, val));
176            }
177        }
178    }
179    if has_mids { out.push('\n'); }
180
181    // Second pass: types
182    for item in &file.items {
183        match item {
184            Item::Namespace(_) | Item::Import(_) | Item::Enum(_) => {}
185            Item::Const(c)   => emit_rust_const(out, c),
186            Item::Struct(s) | Item::Table(s) => emit_rust_struct(out, s),
187            Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => emit_rust_message(out, m, opts),
188        }
189    }
190}
191
192fn emit_rust_const(out: &mut String, c: &ConstDecl) {
193    let val = rust_literal_str(&c.value);
194    let ty = rust_field_type_str(&c.ty);
195    out.push_str(&format!("pub const {}: {} = {};\n\n", c.name, ty, val));
196}
197
198fn emit_rust_struct(out: &mut String, s: &StructDef) {
199    for line in &s.doc {
200        if line.is_empty() { out.push_str("///\n"); } else { out.push_str(&format!("/// {line}\n")); }
201    }
202    out.push_str("#[repr(C)]\n");
203    out.push_str(&format!("pub struct {} {{\n", s.name));
204    for f in &s.fields {
205        out.push_str(&format!("    pub {}: {},\n", f.name, rust_field_type_str(&f.ty)));
206    }
207    out.push_str("}\n\n");
208}
209
210fn emit_rust_message(out: &mut String, m: &MessageDef, opts: &RustOptions) {
211    let header_type = if packet_is_command(m) { opts.cmd_header } else { opts.tlm_header };
212    let qualified = if opts.cfs_module.is_empty() {
213        header_type.to_string()
214    } else {
215        format!("{}::{}", opts.cfs_module, header_type)
216    };
217
218    for line in &m.doc {
219        if line.is_empty() {
220            out.push_str("///\n");
221        } else {
222            out.push_str(&format!("/// {line}\n"));
223        }
224    }
225
226    out.push_str("#[repr(C)]\n");
227    out.push_str(&format!("pub struct {} {{\n", m.name));
228    out.push_str(&format!("    pub cfs_header: {},\n", qualified));
229    for f in &m.fields {
230        let ty = rust_field_type_str(&f.ty);
231        out.push_str(&format!("    pub {}: {},\n", f.name, ty));
232    }
233    out.push_str("}\n\n");
234}
235
236fn rust_field_type_str(ty: &TypeExpr) -> String {
237    if ty.base == BaseType::String {
238        return match &ty.array {
239            None | Some(ArraySuffix::Dynamic) => "*const u8".to_string(),
240            Some(ArraySuffix::Fixed(n)) | Some(ArraySuffix::Bounded(n)) => {
241                format!("[u8; {}]", n)
242            }
243        };
244    }
245
246    let base = rust_base_type_str(&ty.base);
247    match &ty.array {
248        None                        => base,
249        Some(ArraySuffix::Fixed(n)) => format!("[{}; {}]", base, n),
250        // Dynamic/bounded: use a raw slice pointer — no alloc in cFS context
251        Some(ArraySuffix::Dynamic)    => format!("*const {}", base),
252        Some(ArraySuffix::Bounded(n)) => format!("*const {}  /* max {} */", base, n),
253    }
254}
255
256fn rust_base_type_str(base: &BaseType) -> String {
257    match base {
258        BaseType::String        => "*const u8".to_string(),
259        BaseType::Primitive(p)  => rust_primitive_str(*p).to_string(),
260        BaseType::Ref(segments) => segments.join("::"),
261    }
262}
263
264fn rust_primitive_str(p: PrimitiveType) -> &'static str {
265    match p {
266        PrimitiveType::F32   => "f32",
267        PrimitiveType::F64   => "f64",
268        PrimitiveType::I8    => "i8",
269        PrimitiveType::I16   => "i16",
270        PrimitiveType::I32   => "i32",
271        PrimitiveType::I64   => "i64",
272        PrimitiveType::U8    => "u8",
273        PrimitiveType::U16   => "u16",
274        PrimitiveType::U32   => "u32",
275        PrimitiveType::U64   => "u64",
276        PrimitiveType::Bool  => "bool",
277        PrimitiveType::Bytes => "*const u8",
278    }
279}
280
281fn rust_mid_str(lit: &Literal) -> String {
282    match lit {
283        Literal::Hex(n) => format!("0x{:04X}", n),
284        Literal::Int(n) => n.to_string(),
285        Literal::Ident(segs) => segs.join("::"),
286        other => rust_literal_str(other),
287    }
288}
289
290fn rust_literal_str(lit: &Literal) -> String {
291    match lit {
292        Literal::Hex(n)          => format!("0x{:X}", n),
293        Literal::Int(n)          => n.to_string(),
294        Literal::Bool(b)         => b.to_string(),
295        Literal::Float(f)        => {
296            let s = format!("{}", f);
297            if s.contains('.') || s.contains('e') { s } else { format!("{}.0", s) }
298        }
299        Literal::Str(s)          => format!("{:?}", s),
300        Literal::Ident(segments) => segments.join("::"),
301    }
302}
303
304// ── Helpers ───────────────────────────────────────────────────────────────────
305
306/// Returns the `@mid` attribute value, if present.
307fn find_mid_attr(attrs: &[Attribute]) -> Option<&Literal> {
308    attrs.iter().find(|a| a.name == "mid").map(|a| &a.value)
309}
310
311fn packet_item(item: &Item) -> Option<&MessageDef> {
312    match item {
313        Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => Some(m),
314        _ => None,
315    }
316}
317
318/// A legacy message is a command if its MID has bit 12 (0x1000) set, or if it has `@cmd`.
319fn packet_is_command(m: &MessageDef) -> bool {
320    match m.kind {
321        PacketKind::Command => return true,
322        PacketKind::Telemetry => return false,
323        PacketKind::Message => {}
324    }
325
326    if m.attrs.iter().any(|a| a.name == "cmd" && a.value != Literal::Bool(false)) {
327        return true;
328    }
329    if let Some(mid) = find_mid_attr(&m.attrs) {
330        if let Some(n) = literal_to_u64(mid) {
331            return (n & 0x1000) != 0;
332        }
333    }
334    false
335}
336
337fn literal_to_u64(lit: &Literal) -> Option<u64> {
338    match lit {
339        Literal::Hex(n) => Some(*n),
340        Literal::Int(n) if *n >= 0 => Some(*n as u64),
341        _ => None,
342    }
343}
344
345/// Format a MID literal for a `#define` line.
346fn literal_mid_str(lit: &Literal) -> String {
347    match lit {
348        Literal::Hex(n) => format!("0x{:04X}U", n),
349        Literal::Int(n) => format!("{}U", n),
350        Literal::Ident(segs) => segs.join("::"),
351        other => literal_str(other),
352    }
353}
354
355fn literal_str(lit: &Literal) -> String {
356    match lit {
357        Literal::Float(f) => {
358            let s = format!("{}", f);
359            if s.contains('.') || s.contains('e') { s } else { format!("{}.0", s) }
360        }
361        Literal::Int(n)          => n.to_string(),
362        Literal::Hex(n)          => format!("0x{:X}U", n),
363        Literal::Bool(b)         => if *b { "1".to_string() } else { "0".to_string() },
364        Literal::Str(s)          => format!("{:?}", s),
365        Literal::Ident(segments) => segments.join("::"),
366    }
367}
368
369fn non_fixed_type_str(ty: &TypeExpr, namespace: &[String]) -> String {
370    if ty.base == BaseType::String {
371        return match &ty.array {
372            None | Some(ArraySuffix::Dynamic) => "const char*".to_string(),
373            Some(ArraySuffix::Fixed(_)) => unreachable!("handled by emit_c_field"),
374            Some(ArraySuffix::Bounded(n)) => format!("char[{}]", n),
375        };
376    }
377
378    let base = base_type_str(&ty.base, namespace);
379    match &ty.array {
380        None                          => base,
381        Some(ArraySuffix::Fixed(_))   => unreachable!("handled by caller"),
382        Some(ArraySuffix::Dynamic)    => format!("CFE_Span_t /* {} */", base),
383        Some(ArraySuffix::Bounded(n)) => format!("CFE_Span_t /* {} max {} */", base, n),
384    }
385}
386
387fn base_type_str(base: &BaseType, namespace: &[String]) -> String {
388    match base {
389        BaseType::String        => "const char*".to_string(),
390        BaseType::Primitive(p)  => primitive_str(*p).to_string(),
391        BaseType::Ref(segments) => c_ref_type_name(segments, namespace),
392    }
393}
394
395fn emit_c_field(out: &mut String, f: &synapse_parser::ast::FieldDef, namespace: &[String]) {
396    match (&f.ty.base, &f.ty.array) {
397        (BaseType::String, Some(ArraySuffix::Fixed(n) | ArraySuffix::Bounded(n))) => {
398            out.push_str(&format!("    char {}[{}];\n", f.name, n));
399        }
400        (_, Some(ArraySuffix::Fixed(n))) => {
401            out.push_str(&format!("    {} {}[{}];\n", base_type_str(&f.ty.base, namespace), f.name, n));
402        }
403        _ => {
404            out.push_str(&format!("    {} {};\n", non_fixed_type_str(&f.ty, namespace), f.name));
405        }
406    }
407}
408
409fn c_decl_type_name(name: &str, namespace: &[String]) -> String {
410    let mut segments = namespace.to_vec();
411    segments.push(name.to_string());
412    format!("{}_t", segments.join("_"))
413}
414
415fn c_ref_type_name(segments: &[String], namespace: &[String]) -> String {
416    let resolved = if segments.len() == 1 && !namespace.is_empty() {
417        let mut resolved = namespace.to_vec();
418        resolved.push(segments[0].clone());
419        resolved
420    } else {
421        segments.to_vec()
422    };
423    if resolved.is_empty() {
424        return "_t".to_string();
425    }
426    format!("{}_t", resolved.join("_"))
427}
428
429fn import_c_header(path: &str) -> String {
430    replace_extension(path, "h")
431}
432
433fn import_rust_module(path: &str) -> String {
434    let header = path.rsplit('/').next().unwrap_or(path);
435    replace_extension(header, "")
436}
437
438fn replace_extension(path: &str, ext: &str) -> String {
439    match path.rsplit_once('.') {
440        Some((stem, _)) if ext.is_empty() => stem.to_string(),
441        Some((stem, _)) => format!("{stem}.{ext}"),
442        None if ext.is_empty() => path.to_string(),
443        None => format!("{path}.{ext}"),
444    }
445}
446
447fn primitive_str(p: PrimitiveType) -> &'static str {
448    match p {
449        PrimitiveType::F32   => "float",
450        PrimitiveType::F64   => "double",
451        PrimitiveType::I8    => "int8_t",
452        PrimitiveType::I16   => "int16_t",
453        PrimitiveType::I32   => "int32_t",
454        PrimitiveType::I64   => "int64_t",
455        PrimitiveType::U8    => "uint8_t",
456        PrimitiveType::U16   => "uint16_t",
457        PrimitiveType::U32   => "uint32_t",
458        PrimitiveType::U64   => "uint64_t",
459        PrimitiveType::Bool  => "bool",
460        PrimitiveType::Bytes => "uint8_t*",
461    }
462}
463
464/// Convert `PascalCase` → `PASCAL_CASE` (screaming snake case).
465fn to_screaming_snake(name: &str) -> String {
466    let mut out = String::new();
467    for (i, ch) in name.chars().enumerate() {
468        if ch.is_uppercase() && i > 0 {
469            out.push('_');
470        }
471        out.push(ch.to_ascii_uppercase());
472    }
473    out
474}
475
476// ── Tests ─────────────────────────────────────────────────────────────────────
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use synapse_parser::ast::parse;
482
483    fn codegen(src: &str) -> String { generate_c(&parse(src).unwrap()) }
484
485    #[test]
486    fn tlm_message_with_hex_mid() {
487        let out = codegen("@mid(0x0801)\nmessage NavTlm { x: f64  y: f64 }");
488        assert!(out.contains("#define NAV_TLM_MID  0x0801U"));
489        assert!(out.contains("CFE_MSG_TelemetryHeader_t Header;"));
490        assert!(out.contains("typedef struct {"));
491        assert!(out.contains("} NavTlm_t;"));
492        assert!(out.contains("    double x;"));
493        assert!(out.contains("    double y;"));
494    }
495
496    #[test]
497    fn cmd_message_detected_by_mid_bit12() {
498        let out = codegen("@mid(0x1880)\nmessage NavCmd { seq: u16 }");
499        assert!(out.contains("#define NAV_CMD_MID  0x1880U"));
500        assert!(out.contains("CFE_MSG_CommandHeader_t Header;"));
501        assert!(out.contains("} NavCmd_t;"));
502    }
503
504    #[test]
505    fn command_uses_command_header() {
506        let out = codegen("@mid(0x0801)\ncommand SetMode { mode: u8 }");
507        assert!(out.contains("#define SET_MODE_MID  0x0801U"));
508        assert!(out.contains("CFE_MSG_CommandHeader_t Header;"));
509        assert!(!out.contains("CFE_MSG_TelemetryHeader_t Header;"));
510    }
511
512    #[test]
513    fn telemetry_uses_telemetry_header() {
514        let out = codegen("@mid(0x1880)\ntelemetry NavState { x: f64 }");
515        assert!(out.contains("#define NAV_STATE_MID  0x1880U"));
516        assert!(out.contains("CFE_MSG_TelemetryHeader_t Header;"));
517        assert!(!out.contains("CFE_MSG_CommandHeader_t Header;"));
518    }
519
520    #[test]
521    fn table_is_plain_data_without_bus_header() {
522        let out = codegen("table NavConfig { max_speed: f64  enabled: bool }");
523        assert!(out.contains("} NavConfig_t;"));
524        assert!(out.contains("    double max_speed;"));
525        assert!(!out.contains("CFE_MSG_CommandHeader_t Header;"));
526        assert!(!out.contains("CFE_MSG_TelemetryHeader_t Header;"));
527    }
528
529    #[test]
530    fn message_without_mid_no_define() {
531        let out = codegen("message Bare { x: f32 }");
532        assert!(!out.contains("#define"));
533        assert!(out.contains("typedef struct {"));
534        assert!(out.contains("CFE_MSG_TelemetryHeader_t Header;"));
535    }
536
537    #[test]
538    fn const_emits_define() {
539        let out = codegen("const NAV_TLM_MID: u16 = 0x0801");
540        assert!(out.contains("#define NAV_TLM_MID  0x801U"));
541    }
542
543    #[test]
544    fn fixed_array_field() {
545        let out = codegen("@mid(0x0802)\nmessage Imu { covariance: f64[9] }");
546        assert!(out.contains("    double covariance[9];"));
547    }
548
549    #[test]
550    fn c_refs_use_declared_typedef_names() {
551        let out = codegen("struct Point { x: f64 }\nmessage Pose { point: Point }");
552        assert!(out.contains("} Point_t;"));
553        assert!(out.contains("    Point_t point;"));
554    }
555
556    #[test]
557    fn c_qualified_refs_use_declared_typedef_names() {
558        let out = codegen("message Stamped { header: std_msgs::Header }");
559        assert!(out.contains("    std_msgs_Header_t header;"));
560    }
561
562    #[test]
563    fn c_bounded_string_uses_inline_storage() {
564        let out = codegen("struct Label { name: string[<=64] }");
565        assert!(out.contains("    char name[64];"));
566    }
567
568    #[test]
569    fn c_imports_emit_header_includes() {
570        let out = codegen(r#"import "std_msgs.syn""#);
571        assert!(out.contains("#include \"std_msgs.h\""));
572    }
573
574    // ── Rust codegen ─────────────────────────────────────────
575
576    fn rust_codegen(src: &str) -> String {
577        generate_rust(&parse(src).unwrap(), &RustOptions::default())
578    }
579
580    #[test]
581    fn rust_tlm_struct() {
582        let out = rust_codegen("@mid(0x0801)\nmessage NavTlm { x: f64  y: f64 }");
583        assert!(out.contains("pub const NAV_TLM_MID: u16 = 0x0801;"));
584        assert!(out.contains("#[repr(C)]"));
585        assert!(out.contains("pub struct NavTlm {"));
586        assert!(out.contains("    pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
587        assert!(out.contains("    pub x: f64,"));
588        assert!(out.contains("    pub y: f64,"));
589    }
590
591    #[test]
592    fn rust_cmd_struct() {
593        let out = rust_codegen("@mid(0x1880)\nmessage NavCmd { seq: u16 }");
594        assert!(out.contains("pub const NAV_CMD_MID: u16 = 0x1880;"));
595        assert!(out.contains("    pub cfs_header: cfs_sys::CFE_MSG_CommandHeader_t,"));
596    }
597
598    #[test]
599    fn rust_command_uses_command_header() {
600        let out = rust_codegen("@mid(0x0801)\ncommand SetMode { mode: u8 }");
601        assert!(out.contains("pub const SET_MODE_MID: u16 = 0x0801;"));
602        assert!(out.contains("    pub cfs_header: cfs_sys::CFE_MSG_CommandHeader_t,"));
603        assert!(!out.contains("CFE_MSG_TelemetryHeader_t"));
604    }
605
606    #[test]
607    fn rust_telemetry_uses_telemetry_header() {
608        let out = rust_codegen("@mid(0x1880)\ntelemetry NavState { x: f64 }");
609        assert!(out.contains("pub const NAV_STATE_MID: u16 = 0x1880;"));
610        assert!(out.contains("    pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
611        assert!(!out.contains("CFE_MSG_CommandHeader_t"));
612    }
613
614    #[test]
615    fn rust_table_is_plain_data_without_bus_header() {
616        let out = rust_codegen("table NavConfig { max_speed: f64  enabled: bool }");
617        assert!(out.contains("pub struct NavConfig {"));
618        assert!(out.contains("    pub max_speed: f64,"));
619        assert!(!out.contains("cfs_header"));
620    }
621
622    #[test]
623    fn rust_fixed_array() {
624        let out = rust_codegen("@mid(0x0802)\nmessage Imu { covariance: f64[9] }");
625        assert!(out.contains("    pub covariance: [f64; 9],"));
626    }
627
628    #[test]
629    fn rust_custom_module() {
630        let opts = RustOptions { cfs_module: "my_cfs", ..Default::default() };
631        let out = generate_rust(&parse("@mid(0x0801)\nmessage T { x: f32 }").unwrap(), &opts);
632        assert!(out.contains("my_cfs::CFE_MSG_TelemetryHeader_t"));
633    }
634
635    #[test]
636    fn rust_bare_module() {
637        let opts = RustOptions { cfs_module: "", ..Default::default() };
638        let out = generate_rust(&parse("@mid(0x0801)\nmessage T { x: f32 }").unwrap(), &opts);
639        assert!(out.contains("    pub cfs_header: CFE_MSG_TelemetryHeader_t,"));
640        assert!(!out.contains("::CFE_MSG_TelemetryHeader_t"));
641    }
642
643    #[test]
644    fn rust_message_can_have_payload_header_field() {
645        let out = rust_codegen("@mid(0x0801)\nmessage Stamped { header: std_msgs::Header }");
646        assert!(out.contains("    pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
647        assert!(out.contains("    pub header: std_msgs::Header,"));
648    }
649
650    #[test]
651    fn rust_const_uses_declared_type() {
652        let out = rust_codegen("const PI: f64 = 3.14\nconst ENABLED: bool = true");
653        assert!(out.contains("pub const PI: f64 = 3.14;"));
654        assert!(out.contains("pub const ENABLED: bool = true;"));
655    }
656
657    #[test]
658    fn rust_bounded_string_uses_inline_storage() {
659        let out = rust_codegen("struct Label { name: string[<=64] }");
660        assert!(out.contains("    pub name: [u8; 64],"));
661    }
662
663    #[test]
664    fn rust_imports_emit_crate_uses() {
665        let out = rust_codegen(r#"import "std_msgs.syn""#);
666        assert!(out.contains("use crate::std_msgs;"));
667    }
668
669    #[test]
670    fn screaming_snake_conversion() {
671        assert_eq!(to_screaming_snake("NavTelemetry"), "NAV_TELEMETRY");
672        assert_eq!(to_screaming_snake("PoseStamped"), "POSE_STAMPED");
673        assert_eq!(to_screaming_snake("Foo"), "FOO");
674    }
675}