Skip to main content

zerodds_rpc/
codegen.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Request/reply codegen data model — Spec §7.5.1.
5//!
6//! This stage (C6.1.B) derives from a [`ServiceDef`] the wire data models
7//! of the request and reply topics. The spec distinguishes two layouts:
8//!
9//! * **Basic service** (Spec §7.5.1.1): _one_ request topic + _one_ reply
10//!   topic per service type, both with an untyped discriminated union
11//!   over all methods. Wire form:
12//!
13//!   ```text
14//!   struct <Service>_Request {
15//!       RequestHeader header;
16//!       union <Service>_Call switch (long _d) {
17//!           case OP_<m1>: <Service>_<m1>_In   m1_args;
18//!           case OP_<m2>: <Service>_<m2>_In   m2_args;
19//!           ...
20//!       };
21//!   };
22//!   ```
23//!
24//!   `<Service>_Reply` analogously with `<Service>_<m>_Out` and `ReplyHeader`.
25//!
26//! * **Enhanced service** (Spec §7.5.1.2): _per method_ a dedicated
27//!   request and reply topic with typed per-method structures.
28//!   Wire form (per method):
29//!
30//!   ```text
31//!   struct <Service>_<m>_In  { /* in/inout params */ };
32//!   struct <Service>_<m>_Out { /* return + out/inout */ };
33//!   ```
34//!
35//! We produce **data structures** here ([`RequestType`], [`ReplyType`])
36//! with member lists — no language codegen. The language backends in
37//! `crates/idl-cpp`, `idl-csharp` and `idl-java` consume the model
38//! and emit bindings.
39//!
40//! Oneway methods:
41//!
42//! * In the basic layout, a oneway method appears in the request union,
43//!   but is **not** included in the reply union — the reply topic
44//!   is nonetheless shared.
45//! * In the enhanced layout, [`build_enhanced_pair`] returns for a
46//!   oneway method `Some(RequestType)` and `None` as the reply (see
47//!   [`MethodPair`]).
48
49extern crate alloc;
50
51use alloc::format;
52use alloc::string::{String, ToString};
53use alloc::vec::Vec;
54
55use crate::error::{RpcError, RpcResult};
56use crate::service_mapping::{MethodDef, ParamDirection, ServiceDef, TypeRef};
57
58/// Layout variant of the service wire model (Spec §7.5.1).
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60pub enum ServiceLayout {
61    /// Spec §7.5.1.1 — one request/reply topic pair per service.
62    Basic,
63    /// Spec §7.5.1.2 — one topic pair per method.
64    Enhanced,
65}
66
67/// Member of a generated wire structure.
68#[derive(Debug, Clone, PartialEq)]
69pub struct StructMember {
70    /// Member name (e.g. `header`, `request_id`, `result`).
71    pub name: String,
72    /// Type reference or synthetic token (e.g. `RequestHeader`).
73    pub type_ref: MemberType,
74}
75
76impl StructMember {
77    /// Constructor.
78    #[must_use]
79    pub fn new(name: impl Into<String>, type_ref: MemberType) -> Self {
80        Self {
81            name: name.into(),
82            type_ref,
83        }
84    }
85}
86
87/// Type of a struct member. Either a known RPC header type
88/// (`RequestHeader`/`ReplyHeader`/`<Service>_Call`) or an IDL type
89/// from `zerodds_idl::ast::TypeSpec`.
90#[derive(Debug, Clone, PartialEq)]
91pub enum MemberType {
92    /// `RequestHeader` aus `common_types.rs`.
93    RequestHeader,
94    /// `ReplyHeader` aus `common_types.rs`.
95    ReplyHeader,
96    /// Synthetic union over all methods of a service. Carrier
97    /// for the basic-layout switch.
98    CallUnion(CallUnionDef),
99    /// A normal IDL type.
100    Idl(TypeRef),
101}
102
103/// Discriminator case of a call union (basic layout).
104#[derive(Debug, Clone, PartialEq)]
105pub struct CallUnionCase {
106    /// Method name (= discriminator label `OP_<name>`).
107    pub method: String,
108    /// Discriminator value (1-based in the order of the `MethodDef` list).
109    pub discriminator: u32,
110    /// `<Service>_<method>_<In|Out>` type name.
111    pub case_type_name: String,
112    /// Member list of the case struct (`In` or `Out`).
113    pub members: Vec<StructMember>,
114}
115
116/// `<Service>_Call` (request) or `<Service>_Result` (reply) union.
117#[derive(Debug, Clone, PartialEq)]
118pub struct CallUnionDef {
119    /// Complete union type name (e.g. `Calculator_Call`).
120    pub name: String,
121    /// Cases. The `Reply` union contains only methods that are not
122    /// `oneway`.
123    pub cases: Vec<CallUnionCase>,
124}
125
126/// Request wire data model.
127///
128/// * Basic layout: one RequestType per service, with `header` +
129///   `union` member over all methods.
130/// * Enhanced layout: one RequestType per method, with `header` +
131///   typed per-method fields.
132#[derive(Debug, Clone, PartialEq)]
133pub struct RequestType {
134    /// Complete struct name (e.g. `Calculator_Request` or
135    /// `Calculator_add_Request`).
136    pub name: String,
137    /// Topic name of this request wire structure.
138    pub topic_name: String,
139    /// Layout variant from which the struct was produced.
140    pub layout: ServiceLayout,
141    /// Method name for enhanced; `None` for basic.
142    pub method: Option<String>,
143    /// Member list (header + body).
144    pub members: Vec<StructMember>,
145}
146
147/// Reply wire data model. Analogous to [`RequestType`].
148#[derive(Debug, Clone, PartialEq)]
149pub struct ReplyType {
150    /// Complete struct name.
151    pub name: String,
152    /// Topic name.
153    pub topic_name: String,
154    /// Layout variant.
155    pub layout: ServiceLayout,
156    /// Method name for enhanced; `None` for basic.
157    pub method: Option<String>,
158    /// Member list.
159    pub members: Vec<StructMember>,
160}
161
162/// Pair for an enhanced method. Reply is `None` for `oneway`.
163#[derive(Debug, Clone, PartialEq)]
164pub struct MethodPair {
165    /// Request wire structure of this method.
166    pub request: RequestType,
167    /// Reply wire structure (`None` for `oneway` methods).
168    pub reply: Option<ReplyType>,
169}
170
171// ---------------------------------------------------------------------
172// Basic service mapping (Spec §7.5.1.1)
173// ---------------------------------------------------------------------
174
175/// Returns the data models of the basic-layout topics
176/// (`<Service>_Request` plus `<Service>_Reply`). An empty `ServiceDef`
177/// returns a pair with empty unions — the caller must decide for itself
178/// whether to discard it.
179///
180/// # Errors
181/// `RpcError::InvalidServiceName` if the service name is empty (this
182/// happens only for manually constructed ServiceDefs — `lower_service`
183/// already validates).
184pub fn build_basic_pair(svc: &ServiceDef) -> RpcResult<(RequestType, ReplyType)> {
185    if svc.name.is_empty() {
186        return Err(RpcError::InvalidServiceName(String::new()));
187    }
188    let topics = svc.topic_names()?;
189    let req_union = build_call_union(svc, /*include_oneway=*/ true, /*reply=*/ false)?;
190    let rep_union = build_call_union(svc, /*include_oneway=*/ false, /*reply=*/ true)?;
191
192    let request = RequestType {
193        name: format!("{}_Request", svc.name),
194        topic_name: topics.request.clone(),
195        layout: ServiceLayout::Basic,
196        method: None,
197        members: alloc::vec![
198            StructMember::new("header", MemberType::RequestHeader),
199            StructMember::new("call", MemberType::CallUnion(req_union)),
200        ],
201    };
202
203    let reply = ReplyType {
204        name: format!("{}_Reply", svc.name),
205        topic_name: topics.reply,
206        layout: ServiceLayout::Basic,
207        method: None,
208        members: alloc::vec![
209            StructMember::new("header", MemberType::ReplyHeader),
210            StructMember::new("result", MemberType::CallUnion(rep_union)),
211        ],
212    };
213
214    Ok((request, reply))
215}
216
217fn build_call_union(
218    svc: &ServiceDef,
219    include_oneway: bool,
220    reply: bool,
221) -> RpcResult<CallUnionDef> {
222    let union_name = if reply {
223        format!("{}_Result", svc.name)
224    } else {
225        format!("{}_Call", svc.name)
226    };
227    let mut cases = Vec::with_capacity(svc.methods.len());
228    let mut discr: u32 = 1;
229    for m in &svc.methods {
230        if m.oneway && !include_oneway {
231            continue;
232        }
233        let case_type = method_struct_name(svc, m, reply);
234        let members = if reply {
235            method_out_members(m)
236        } else {
237            method_in_members(m)
238        };
239        cases.push(CallUnionCase {
240            method: m.name.clone(),
241            discriminator: discr,
242            case_type_name: case_type,
243            members,
244        });
245        discr = discr
246            .checked_add(1)
247            .ok_or_else(|| RpcError::Codec("more than u32::MAX methods in service".to_string()))?;
248    }
249    Ok(CallUnionDef {
250        name: union_name,
251        cases,
252    })
253}
254
255// ---------------------------------------------------------------------
256// Enhanced-Service-Mapping (Spec §7.5.1.2)
257// ---------------------------------------------------------------------
258
259/// Returns the per-method pair (request + optional reply).
260///
261/// Reply is `None` if the method is `oneway`.
262///
263/// # Errors
264/// `RpcError::InvalidServiceName` resp. `RpcError::InvalidMethodName`
265/// on inconsistent ServiceDefs.
266pub fn build_enhanced_pair(svc: &ServiceDef, method: &MethodDef) -> RpcResult<MethodPair> {
267    if svc.name.is_empty() {
268        return Err(RpcError::InvalidServiceName(String::new()));
269    }
270    if method.name.is_empty() {
271        return Err(RpcError::InvalidMethodName(String::new()));
272    }
273    // Enhanced-Topic-Naming: `<Service>_<Method>_Request` /
274    // `<Service>_<Method>_Reply` (Spec §7.5.1.2 + §7.8.2 allows vendor-
275    // extension; Cyclone/FastDDS use this form).
276    let request_topic = format!(
277        "{}_{}{}",
278        svc.name,
279        method.name,
280        crate::topic_naming::REQUEST_SUFFIX
281    );
282    let reply_topic = format!(
283        "{}_{}{}",
284        svc.name,
285        method.name,
286        crate::topic_naming::REPLY_SUFFIX
287    );
288
289    let mut req_members = alloc::vec![StructMember::new("header", MemberType::RequestHeader)];
290    req_members.extend(method_in_members(method));
291    let request = RequestType {
292        name: format!("{}_{}_Request", svc.name, method.name),
293        topic_name: request_topic,
294        layout: ServiceLayout::Enhanced,
295        method: Some(method.name.clone()),
296        members: req_members,
297    };
298
299    let reply = if method.oneway {
300        None
301    } else {
302        let mut rep_members = alloc::vec![StructMember::new("header", MemberType::ReplyHeader)];
303        rep_members.extend(method_out_members(method));
304        Some(ReplyType {
305            name: format!("{}_{}_Reply", svc.name, method.name),
306            topic_name: reply_topic,
307            layout: ServiceLayout::Enhanced,
308            method: Some(method.name.clone()),
309            members: rep_members,
310        })
311    };
312
313    Ok(MethodPair { request, reply })
314}
315
316/// Returns all enhanced-layout pairs of a service.
317///
318/// # Errors
319/// See [`build_enhanced_pair`].
320pub fn build_enhanced_all(svc: &ServiceDef) -> RpcResult<Vec<MethodPair>> {
321    let mut out = Vec::with_capacity(svc.methods.len());
322    for m in &svc.methods {
323        out.push(build_enhanced_pair(svc, m)?);
324    }
325    Ok(out)
326}
327
328// ---------------------------------------------------------------------
329// Helpers
330// ---------------------------------------------------------------------
331
332fn method_struct_name(svc: &ServiceDef, m: &MethodDef, reply: bool) -> String {
333    if reply {
334        format!("{}_{}_Out", svc.name, m.name)
335    } else {
336        format!("{}_{}_In", svc.name, m.name)
337    }
338}
339
340fn method_in_members(m: &MethodDef) -> Vec<StructMember> {
341    m.params
342        .iter()
343        .filter(|p| p.direction.is_in())
344        .map(|p| StructMember::new(p.name.clone(), MemberType::Idl(p.type_ref.clone())))
345        .collect()
346}
347
348fn method_out_members(m: &MethodDef) -> Vec<StructMember> {
349    let mut out = Vec::new();
350    if let Some(ret) = &m.return_type {
351        out.push(StructMember::new("_return", MemberType::Idl(ret.clone())));
352    }
353    for p in m.params.iter().filter(|p| p.direction.is_out()) {
354        // `out` and `inout` both land in the reply.
355        let _ = ParamDirection::Out;
356        out.push(StructMember::new(
357            p.name.clone(),
358            MemberType::Idl(p.type_ref.clone()),
359        ));
360    }
361    out
362}
363
364#[cfg(test)]
365#[allow(
366    clippy::unwrap_used,
367    clippy::expect_used,
368    clippy::panic,
369    clippy::unreachable
370)]
371mod tests {
372    use super::*;
373    use crate::annotations::lower_rpc_annotations;
374    use crate::service_mapping::{ParamDef, lower_service};
375    use zerodds_idl::ast::{
376        Annotation, AnnotationParams, Export, Identifier, IntegerType, InterfaceDef, InterfaceKind,
377        OpDecl, ParamAttribute, ParamDecl, PrimitiveType, ScopedName, StringType, TypeSpec,
378    };
379    use zerodds_idl::errors::Span;
380
381    fn sp() -> Span {
382        Span::SYNTHETIC
383    }
384
385    fn ident(t: &str) -> Identifier {
386        Identifier::new(t, sp())
387    }
388
389    fn long_t() -> TypeSpec {
390        TypeSpec::Primitive(PrimitiveType::Integer(IntegerType::Long))
391    }
392
393    fn string_t() -> TypeSpec {
394        TypeSpec::String(StringType {
395            wide: false,
396            bound: None,
397            span: sp(),
398        })
399    }
400
401    fn op(name: &str, oneway: bool, ret: Option<TypeSpec>, params: Vec<ParamDecl>) -> OpDecl {
402        OpDecl {
403            name: ident(name),
404            oneway,
405            return_type: ret,
406            params,
407            raises: Vec::new(),
408            context: Vec::new(),
409            annotations: Vec::new(),
410            span: sp(),
411        }
412    }
413
414    fn param(name: &str, attr: ParamAttribute, ty: TypeSpec) -> ParamDecl {
415        ParamDecl {
416            attribute: attr,
417            type_spec: ty,
418            name: ident(name),
419            annotations: Vec::new(),
420            span: sp(),
421        }
422    }
423
424    fn ann_simple(name: &str) -> Annotation {
425        Annotation {
426            name: ScopedName {
427                absolute: false,
428                parts: alloc::vec![ident(name)],
429                span: sp(),
430            },
431            params: AnnotationParams::None,
432            span: sp(),
433        }
434    }
435
436    fn calc_service() -> ServiceDef {
437        let add = op(
438            "add",
439            false,
440            Some(long_t()),
441            alloc::vec![
442                param("a", ParamAttribute::In, long_t()),
443                param("b", ParamAttribute::In, long_t()),
444            ],
445        );
446        let log = op(
447            "log",
448            true, // oneway
449            None,
450            alloc::vec![param("msg", ParamAttribute::In, string_t())],
451        );
452        let i = InterfaceDef {
453            kind: InterfaceKind::Plain,
454            name: ident("Calculator"),
455            bases: Vec::new(),
456            exports: alloc::vec![Export::Op(add), Export::Op(log)],
457            annotations: alloc::vec![ann_simple("service")],
458            span: sp(),
459        };
460        let lowered = lower_rpc_annotations(&i.annotations);
461        lower_service(&i, &lowered).unwrap()
462    }
463
464    #[test]
465    fn basic_pair_topic_names() {
466        let svc = calc_service();
467        let (req, rep) = build_basic_pair(&svc).unwrap();
468        assert_eq!(req.topic_name, "Calculator_Request");
469        assert_eq!(rep.topic_name, "Calculator_Reply");
470    }
471
472    #[test]
473    fn basic_pair_layout_marker() {
474        let svc = calc_service();
475        let (req, rep) = build_basic_pair(&svc).unwrap();
476        assert_eq!(req.layout, ServiceLayout::Basic);
477        assert_eq!(rep.layout, ServiceLayout::Basic);
478        assert_eq!(req.method, None);
479        assert_eq!(rep.method, None);
480    }
481
482    #[test]
483    fn basic_request_has_header_and_call_union() {
484        let svc = calc_service();
485        let (req, _) = build_basic_pair(&svc).unwrap();
486        assert_eq!(req.members.len(), 2);
487        assert_eq!(req.members[0].name, "header");
488        assert!(matches!(req.members[0].type_ref, MemberType::RequestHeader));
489        assert_eq!(req.members[1].name, "call");
490        let call_union = match &req.members[1].type_ref {
491            MemberType::CallUnion(u) => u,
492            _ => panic!("expected CallUnion"),
493        };
494        assert_eq!(call_union.name, "Calculator_Call");
495        // Both methods including oneway `log` must be in the request union.
496        assert_eq!(call_union.cases.len(), 2);
497        assert_eq!(call_union.cases[0].method, "add");
498        assert_eq!(call_union.cases[0].discriminator, 1);
499        assert_eq!(call_union.cases[0].case_type_name, "Calculator_add_In");
500        assert_eq!(call_union.cases[1].method, "log");
501        assert_eq!(call_union.cases[1].discriminator, 2);
502    }
503
504    #[test]
505    fn basic_reply_excludes_oneway_methods() {
506        let svc = calc_service();
507        let (_, rep) = build_basic_pair(&svc).unwrap();
508        let result_union = match &rep.members[1].type_ref {
509            MemberType::CallUnion(u) => u,
510            _ => panic!("expected CallUnion"),
511        };
512        assert_eq!(result_union.name, "Calculator_Result");
513        // `log` is oneway → not in the reply.
514        assert_eq!(result_union.cases.len(), 1);
515        assert_eq!(result_union.cases[0].method, "add");
516        assert_eq!(result_union.cases[0].case_type_name, "Calculator_add_Out");
517    }
518
519    #[test]
520    fn basic_request_in_params_become_case_members() {
521        let svc = calc_service();
522        let (req, _) = build_basic_pair(&svc).unwrap();
523        let call_union = match &req.members[1].type_ref {
524            MemberType::CallUnion(u) => u,
525            _ => unreachable!(),
526        };
527        let add_case = &call_union.cases[0];
528        assert_eq!(add_case.members.len(), 2);
529        assert_eq!(add_case.members[0].name, "a");
530        assert_eq!(add_case.members[1].name, "b");
531    }
532
533    #[test]
534    fn basic_reply_return_value_first_member() {
535        let svc = calc_service();
536        let (_, rep) = build_basic_pair(&svc).unwrap();
537        let result_union = match &rep.members[1].type_ref {
538            MemberType::CallUnion(u) => u,
539            _ => unreachable!(),
540        };
541        let add_case = &result_union.cases[0];
542        assert_eq!(add_case.members.len(), 1);
543        assert_eq!(add_case.members[0].name, "_return");
544    }
545
546    #[test]
547    fn enhanced_pair_topic_names() {
548        let svc = calc_service();
549        let pair = build_enhanced_pair(&svc, &svc.methods[0]).unwrap();
550        assert_eq!(pair.request.topic_name, "Calculator_add_Request");
551        assert_eq!(
552            pair.reply.as_ref().unwrap().topic_name,
553            "Calculator_add_Reply"
554        );
555    }
556
557    #[test]
558    fn enhanced_pair_layout_marker() {
559        let svc = calc_service();
560        let pair = build_enhanced_pair(&svc, &svc.methods[0]).unwrap();
561        assert_eq!(pair.request.layout, ServiceLayout::Enhanced);
562        assert_eq!(pair.request.method, Some("add".to_string()));
563    }
564
565    #[test]
566    fn enhanced_oneway_has_no_reply() {
567        let svc = calc_service();
568        let log = svc.methods.iter().find(|m| m.oneway).unwrap();
569        let pair = build_enhanced_pair(&svc, log).unwrap();
570        assert!(pair.reply.is_none());
571        // Request members: header + msg.
572        assert_eq!(pair.request.members.len(), 2);
573        assert_eq!(pair.request.members[0].name, "header");
574        assert_eq!(pair.request.members[1].name, "msg");
575    }
576
577    #[test]
578    fn enhanced_pair_request_in_params() {
579        let svc = calc_service();
580        let pair = build_enhanced_pair(&svc, &svc.methods[0]).unwrap();
581        // header + a + b
582        assert_eq!(pair.request.members.len(), 3);
583        assert_eq!(pair.request.members[0].name, "header");
584        assert_eq!(pair.request.members[1].name, "a");
585        assert_eq!(pair.request.members[2].name, "b");
586    }
587
588    #[test]
589    fn enhanced_pair_reply_return_only() {
590        let svc = calc_service();
591        let pair = build_enhanced_pair(&svc, &svc.methods[0]).unwrap();
592        let rep = pair.reply.as_ref().unwrap();
593        // header + _return
594        assert_eq!(rep.members.len(), 2);
595        assert_eq!(rep.members[0].name, "header");
596        assert_eq!(rep.members[1].name, "_return");
597    }
598
599    #[test]
600    fn enhanced_inout_param_appears_in_both_request_and_reply() {
601        let m = op(
602            "swap",
603            false,
604            None,
605            alloc::vec![param("v", ParamAttribute::InOut, long_t())],
606        );
607        let svc = ServiceDef {
608            name: "Swap".into(),
609            methods: alloc::vec![MethodDef {
610                name: "swap".into(),
611                params: alloc::vec![ParamDef {
612                    name: "v".into(),
613                    direction: ParamDirection::InOut,
614                    type_ref: long_t(),
615                }],
616                return_type: None,
617                oneway: false,
618            }],
619        };
620        let _ = m;
621        let pair = build_enhanced_pair(&svc, &svc.methods[0]).unwrap();
622        assert!(pair.request.members.iter().any(|m| m.name == "v"));
623        let rep = pair.reply.as_ref().unwrap();
624        assert!(rep.members.iter().any(|m| m.name == "v"));
625    }
626
627    #[test]
628    fn enhanced_all_skips_no_method() {
629        let svc = calc_service();
630        let pairs = build_enhanced_all(&svc).unwrap();
631        assert_eq!(pairs.len(), 2);
632        assert_eq!(pairs[0].request.method, Some("add".to_string()));
633        assert_eq!(pairs[1].request.method, Some("log".to_string()));
634        assert!(pairs[1].reply.is_none()); // log is oneway.
635    }
636
637    #[test]
638    fn empty_service_yields_empty_unions_in_basic() {
639        let svc = ServiceDef {
640            name: "Empty".into(),
641            methods: Vec::new(),
642        };
643        let (req, rep) = build_basic_pair(&svc).unwrap();
644        let req_u = match &req.members[1].type_ref {
645            MemberType::CallUnion(u) => u,
646            _ => unreachable!(),
647        };
648        let rep_u = match &rep.members[1].type_ref {
649            MemberType::CallUnion(u) => u,
650            _ => unreachable!(),
651        };
652        assert_eq!(req_u.cases.len(), 0);
653        assert_eq!(rep_u.cases.len(), 0);
654    }
655
656    #[test]
657    fn invalid_service_name_is_error_in_codegen() {
658        let svc = ServiceDef {
659            name: String::new(),
660            methods: Vec::new(),
661        };
662        let err = build_basic_pair(&svc).unwrap_err();
663        assert!(matches!(err, RpcError::InvalidServiceName(_)));
664    }
665
666    #[test]
667    fn enhanced_method_with_invalid_name_is_error() {
668        let svc = ServiceDef {
669            name: "S".into(),
670            methods: alloc::vec![MethodDef {
671                name: String::new(),
672                params: Vec::new(),
673                return_type: None,
674                oneway: false,
675            }],
676        };
677        let err = build_enhanced_pair(&svc, &svc.methods[0]).unwrap_err();
678        assert!(matches!(err, RpcError::InvalidMethodName(_)));
679    }
680}