Skip to main content

zerodds_webdds_bridge/
types_registry.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Type resolution: turns a config-named IDL file, or a type a peer's
5//! `DcpsRuntime` TypeLookup discovers, into a
6//! [`zerodds_dcps::serialized_type::SerializedTypeSupport`] plus the
7//! reflective [`zerodds_types::dynamic::DynamicType`] view the XML body
8//! converter (`crate::dynxml`) walks.
9//!
10//! Two sources, per the task's requirement that types come "from IDL/XML
11//! type files named in the config or from TypeLookup of discovered
12//! types":
13//!
14//! * [`TypeRegistry::load_idl_file`] — parses a `.idl` file named in the
15//!   daemon config through the same front end `zerodds-dcps` itself uses
16//!   for its own tests (`zerodds_wire_probe::encode::model_from_idl`),
17//!   derives the type's `CompleteTypeObject`
18//!   (`zerodds_types::from_ast::complete_type_object`), and builds a
19//!   [`SerializedTypeSupport`] from it. Fully working.
20//! * [`TypeRegistry::resolve_discovered`] — for a type this daemon never
21//!   read from a file: given the `EquivalenceHash` a peer's SEDP
22//!   announced, reads the `CompleteTypeObject` (and its transitive
23//!   dependencies) back out of the participant's own TypeLookup registry
24//!   (`DcpsRuntime::type_lookup_server`, a public field) once
25//!   `send_type_lookup_request` has resolved it, re-encodes the objects
26//!   to bytes and feeds them through the same public
27//!   `SerializedTypeSupport::from_type_objects` constructor the IDL path
28//!   uses.
29//! * [`TypeRegistry::resolve_named`] — waits (bounded, event-driven on
30//!   [`zerodds_types::resolve::TypeRegistry::generation`], no polling
31//!   sleep) for a topic's type to resolve via TypeLookup when a request
32//!   names a topic this daemon does not know the type of yet — see
33//!   `crate::gateway`'s use of it.
34//!
35//! `keyed`/`extensibility` are never brute-forced: XTypes 1.3 §7.3.4.5
36//! states a struct's/union's extensibility in its own `struct_flags`/
37//! `union_flags` (`IS_FINAL`/`IS_APPENDABLE`/`IS_MUTABLE`) and a
38//! member's `@key` in its `member_flags` (`IS_KEY`, §7.3.4.6) — both
39//! read directly off the `CompleteTypeObject` (via the model
40//! `ModelFromTypeObjects` builds from it) in `resolve_from_complete`,
41//! the same way `SerializedTypeSupport`'s own (private)
42//! `from_decoded_objects` derives them internally.
43//!
44//! **DDS-XTYPES XML type-definition files.** `zerodds_xml::typeobject_bridge`
45//! now builds a `CompleteTypeObject` from a `<types>` document
46//! (`xml_type_to_complete_typeobject`, XTypes 1.3 §7.3.4.5/§Annex A) —
47//! [`TypeRegistry::load_xml_file`] uses it the same way
48//! [`TypeRegistry::load_idl_file`] uses the IDL front end.
49//!
50//! **A fifth source that is not a file at all.**
51//! [`TypeRegistry::resolve_synthetic`] registers a `CompleteTypeObject`
52//! this crate built itself, rather than read from anywhere — the
53//! `In`/`Out` structs `crate::rpc_dispatch` synthesizes for a business
54//! `interface` operation's parameters (DDS-RPC 1.0
55//! §7.5.1.1.4/§7.5.1.1.5). It goes through the same
56//! `resolve_from_complete` as every other source, so a synthetic
57//! type's `keyed`/`extensibility` is read off itself exactly like a
58//! discovered or file-loaded one, never asserted by the caller.
59
60use std::collections::HashMap;
61use std::sync::{Arc, Mutex};
62
63use zerodds_ast::Ast;
64use zerodds_dcps::runtime::DcpsRuntime;
65use zerodds_dcps::serialized_type::SerializedTypeSupport;
66use zerodds_types::EquivalenceHash;
67use zerodds_types::dynamic::DynamicType;
68use zerodds_types::type_object::TypeObject;
69
70/// One resolved type: the wire codec (`support`) plus the reflective
71/// view (`dynamic_type`) the XML converter walks. Both are built from
72/// the same `CompleteTypeObject`, so they always agree on shape.
73#[derive(Clone)]
74pub struct ResolvedType {
75    /// Wire codec + key-hash support (`crates/dcps`'s runtime-described
76    /// topic path).
77    pub support: Arc<SerializedTypeSupport>,
78    /// Reflective member/kind view for `dynxml`.
79    pub dynamic_type: DynamicType,
80    /// `true` if the type carries an `@key`.
81    pub keyed: bool,
82}
83
84impl std::fmt::Debug for ResolvedType {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("ResolvedType")
87            .field("type_name", &self.dynamic_type.name())
88            .field("keyed", &self.keyed)
89            .finish_non_exhaustive()
90    }
91}
92
93/// Error resolving a type.
94#[derive(Debug)]
95pub enum TypeResolveError {
96    /// The IDL file could not be read.
97    Io(std::io::Error),
98    /// The IDL text did not parse, or the named type is not in it.
99    Idl(String),
100    /// `CompleteTypeObject` derivation, or `SerializedTypeSupport`
101    /// construction, refused the type.
102    Model(String),
103    /// The named type is not in the loaded model.
104    NotFound(String),
105    /// The type is not yet resolved via TypeLookup (still pending, or
106    /// TypeLookup was never asked for it).
107    NotYetResolved,
108    /// No discovered publication or subscription names this topic, so
109    /// there is no `TypeIdentifier` to even ask a peer about.
110    NoDiscoveredEndpoint(String),
111    /// A `getTypes` request was sent and awaited, but the type never
112    /// resolved within the bound.
113    TimedOut(String),
114}
115
116impl std::fmt::Display for TypeResolveError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            Self::Io(e) => write!(f, "io: {e}"),
120            Self::Idl(m) => write!(f, "idl: {m}"),
121            Self::Model(m) => write!(f, "type model: {m}"),
122            Self::NotFound(name) => write!(f, "type `{name}` not found"),
123            Self::NotYetResolved => f.write_str("type not yet resolved via TypeLookup"),
124            Self::NoDiscoveredEndpoint(topic) => {
125                write!(
126                    f,
127                    "no discovered publication or subscription names topic `{topic}`"
128                )
129            }
130            Self::TimedOut(name) => write!(
131                f,
132                "type `{name}` never resolved via TypeLookup within the bound"
133            ),
134        }
135    }
136}
137
138impl std::error::Error for TypeResolveError {}
139
140/// Type registry: caches every type resolved so far, by name.
141#[derive(Default)]
142pub struct TypeRegistry {
143    by_name: Mutex<HashMap<String, ResolvedType>>,
144}
145
146impl TypeRegistry {
147    /// Empty registry.
148    #[must_use]
149    pub fn new() -> Self {
150        Self::default()
151    }
152
153    /// A previously resolved type, if any.
154    #[must_use]
155    pub fn get(&self, type_name: &str) -> Option<ResolvedType> {
156        self.by_name
157            .lock()
158            .ok()
159            .and_then(|m| m.get(type_name).cloned())
160    }
161
162    /// Registers an already-resolved type under its name (idempotent —
163    /// a later call with the same name replaces the entry, since the
164    /// daemon reloads its config on SIGHUP).
165    pub fn insert(&self, type_name: &str, resolved: ResolvedType) {
166        if let Ok(mut m) = self.by_name.lock() {
167            m.insert(type_name.to_string(), resolved);
168        }
169    }
170
171    /// Parses `idl_path`, resolves `type_name` in it, and registers the
172    /// result. `type_name` is the IDL scoped name (`Mod::Type`), which
173    /// is also the DDS `type_name` samples of this topic carry on the
174    /// wire.
175    ///
176    /// # Errors
177    /// See [`TypeResolveError`].
178    pub fn load_idl_file(
179        &self,
180        idl_path: &std::path::Path,
181        type_name: &str,
182    ) -> Result<ResolvedType, TypeResolveError> {
183        let source = std::fs::read_to_string(idl_path).map_err(TypeResolveError::Io)?;
184        let ast = Arc::new(
185            zerodds_wire_probe::encode::model_from_idl(&source, &idl_path.to_string_lossy())
186                .map_err(|e| TypeResolveError::Idl(format!("{e}")))?,
187        );
188        let resolved = resolve_from_ast(&ast, type_name)?;
189        self.insert(type_name, resolved.clone());
190        Ok(resolved)
191    }
192
193    /// Parses `xml_path` as a DDS-XML 1.0 `<types>`/`<dds>` document
194    /// (`zerodds_xml::parse_type_libraries`), resolves `type_name` in
195    /// it (a top-level, or module-flattened `Outer::Inner`, name) and
196    /// registers the result. Every other type the library declares is
197    /// offered as a dependency (`SerializedTypeSupport` only uses what
198    /// it needs) — the same "offer the whole model" shape
199    /// [`Self::load_idl_file`] uses for the IDL front end.
200    ///
201    /// # Errors
202    /// [`TypeResolveError::Io`] if the file cannot be read.
203    /// [`TypeResolveError::Idl`] if it does not parse as DDS-XML, or
204    /// declares no `<types>` library at all. [`TypeResolveError::NotFound`]
205    /// if `type_name` is not among the library's (flattened) type names.
206    /// [`TypeResolveError::Model`] if the bridge to `CompleteTypeObject`
207    /// refuses a construct (`zerodds_xml::typeobject_bridge`'s
208    /// documented gaps — a named sequence/array typedef, for one).
209    pub fn load_xml_file(
210        &self,
211        xml_path: &std::path::Path,
212        type_name: &str,
213    ) -> Result<ResolvedType, TypeResolveError> {
214        let source = std::fs::read_to_string(xml_path).map_err(TypeResolveError::Io)?;
215        let libraries = zerodds_xml::parse_type_libraries(&source)
216            .map_err(|e| TypeResolveError::Idl(format!("{e}")))?;
217        let mut objects: HashMap<String, zerodds_types::CompleteTypeObject> = HashMap::new();
218        for lib in &libraries {
219            let bridged = zerodds_xml::bridge_library_complete(lib)
220                .map_err(|e| TypeResolveError::Model(format!("{e}")))?;
221            objects.extend(bridged);
222        }
223        let Some(root) = objects.remove(type_name) else {
224            return Err(TypeResolveError::NotFound(format!(
225                "{type_name} (not declared in {})",
226                xml_path.display()
227            )));
228        };
229        let dependencies: Vec<zerodds_types::CompleteTypeObject> = objects.into_values().collect();
230        let resolved = resolve_from_complete(type_name, root, dependencies)?;
231        self.insert(type_name, resolved.clone());
232        Ok(resolved)
233    }
234
235    /// Resolves `type_name` from a discovered `hash` once the
236    /// participant's `DcpsRuntime` TypeLookup registry holds its
237    /// `CompleteTypeObject` (own `register_type_object` calls,
238    /// `getTypes` replies from a peer, or `send_type_lookup_request`
239    /// having completed). Caches the result under `type_name`.
240    ///
241    /// # Errors
242    /// [`TypeResolveError::NotYetResolved`] if the registry does not
243    /// (yet) hold the hash's complete object — the caller should retry
244    /// after issuing/awaiting `DcpsRuntime::send_type_lookup_request`.
245    pub fn resolve_discovered(
246        &self,
247        runtime: &DcpsRuntime,
248        type_name: &str,
249        hash: EquivalenceHash,
250    ) -> Result<ResolvedType, TypeResolveError> {
251        let (root, dependencies) = {
252            let server = runtime
253                .type_lookup_server
254                .lock()
255                .map_err(|_| TypeResolveError::Model("type_lookup_server poisoned".into()))?;
256            let Some(root) = server.registry.get_complete(&hash).cloned() else {
257                return Err(TypeResolveError::NotYetResolved);
258            };
259            let dep_hashes = server
260                .registry
261                .transitive_dependencies(&hash, zerodds_types::resolve::DEFAULT_MAX_RESOLVE_NODES);
262            let mut deps = Vec::with_capacity(dep_hashes.len());
263            for dep_hash in &dep_hashes {
264                let Some(dep) = server.registry.get_complete(dep_hash).cloned() else {
265                    return Err(TypeResolveError::NotYetResolved);
266                };
267                deps.push(dep);
268            }
269            (root, deps)
270        };
271        let resolved = resolve_from_complete(type_name, root, dependencies)?;
272        self.insert(type_name, resolved.clone());
273        Ok(resolved)
274    }
275
276    /// Resolves `type_name` for `topic_name` automatically: if a
277    /// discovered publication or subscription names `topic_name`, its
278    /// SEDP-announced `TypeIdentifier` is asked for from the peer that
279    /// owns it (`DcpsRuntime::send_type_lookup_request`) and awaited,
280    /// bounded by `timeout`. The wait is generation-driven, not a bare
281    /// sleep loop: [`zerodds_types::resolve::TypeRegistry::generation`]
282    /// is read every tick, and the (cheap) resolve attempt against the
283    /// registry only re-runs when it has moved since the last one —
284    /// the same short-circuit `DcpsRuntime`'s own receive path uses
285    /// (`UserReaderSlot::resolve_key_hash_support`) to avoid rebuilding
286    /// a model on every tick a reply has not (yet) landed.
287    ///
288    /// A response arrives on the receive thread via a callback that
289    /// only ever updates the registry (`DcpsRuntime` has no dedicated
290    /// wake signal for a TypeLookup reply the way `WaitSet`'s samples
291    /// do) — this is the boundedly-polled substitute for a
292    /// zero-poll wait; see the crate README "Open points".
293    ///
294    /// # Errors
295    /// [`TypeResolveError::NoDiscoveredEndpoint`] if nothing discovered
296    /// names `topic_name` at all (there is then no `TypeIdentifier` to
297    /// even ask about). [`TypeResolveError::TimedOut`] if a request was
298    /// sent and awaited but the type never resolved within `timeout`.
299    pub fn resolve_named(
300        &self,
301        runtime: &DcpsRuntime,
302        topic_name: &str,
303        type_name: &str,
304        timeout: core::time::Duration,
305    ) -> Result<ResolvedType, TypeResolveError> {
306        let endpoint = runtime
307            .discovered_publication_endpoints()
308            .into_iter()
309            .chain(runtime.discovered_subscription_endpoints())
310            .find(|e| e.topic_name == topic_name)
311            .ok_or_else(|| TypeResolveError::NoDiscoveredEndpoint(topic_name.to_string()))?;
312
313        let zerodds_types::TypeIdentifier::EquivalenceHashComplete(hash) =
314            endpoint.type_identifier.clone()
315        else {
316            return Err(TypeResolveError::Model(format!(
317                "topic `{topic_name}`'s discovered TypeIdentifier is not the \
318                 EquivalenceHashComplete form this daemon can resolve"
319            )));
320        };
321
322        match self.resolve_discovered(runtime, type_name, hash) {
323            Ok(r) => return Ok(r),
324            Err(TypeResolveError::NotYetResolved) => {}
325            Err(e) => return Err(e),
326        }
327
328        let mut peer_prefix = [0u8; 12];
329        peer_prefix.copy_from_slice(&endpoint.endpoint_guid[..12]);
330        let peer = zerodds_rtps::wire_types::GuidPrefix(peer_prefix);
331        let _ = runtime.send_type_lookup_request(peer, &[endpoint.type_identifier.clone()]);
332
333        let deadline = std::time::Instant::now() + timeout;
334        let mut last_generation = current_generation(runtime);
335        loop {
336            if std::time::Instant::now() >= deadline {
337                return Err(TypeResolveError::TimedOut(type_name.to_string()));
338            }
339            std::thread::sleep(core::time::Duration::from_millis(10));
340            let generation = current_generation(runtime);
341            if generation == last_generation {
342                continue;
343            }
344            last_generation = generation;
345            match self.resolve_discovered(runtime, type_name, hash) {
346                Ok(r) => return Ok(r),
347                Err(TypeResolveError::NotYetResolved) => {}
348                Err(e) => return Err(e),
349            }
350        }
351    }
352
353    /// Registers a `CompleteTypeObject` this crate synthesized itself
354    /// (not read from an IDL file, an XML `<types>` document, or
355    /// TypeLookup) — `crate::rpc_dispatch`'s per-operation In/Out
356    /// structs, DDS-RPC 1.0 §7.5.1.1.4/§7.5.1.1.5, built from an
357    /// operation's parameter list via
358    /// `zerodds_types::from_ast::type_identifier`. Goes through the
359    /// same `resolve_from_complete` every other source does, so a
360    /// synthetic type is validated (its `keyed`/`extensibility` read
361    /// off itself, never asserted by the caller) exactly like a
362    /// discovered or file-loaded one.
363    ///
364    /// # Errors
365    /// See [`TypeResolveError`].
366    pub fn resolve_synthetic(
367        &self,
368        type_name: &str,
369        root: zerodds_types::CompleteTypeObject,
370        dependencies: Vec<zerodds_types::CompleteTypeObject>,
371    ) -> Result<ResolvedType, TypeResolveError> {
372        let resolved = resolve_from_complete(type_name, root, dependencies)?;
373        self.insert(type_name, resolved.clone());
374        Ok(resolved)
375    }
376}
377
378/// The TypeLookup registry's generation counter, or `0` on a poisoned
379/// lock (treated as "unknown", so the caller's first comparison always
380/// looks unchanged rather than panicking).
381fn current_generation(runtime: &DcpsRuntime) -> u64 {
382    runtime
383        .type_lookup_server
384        .lock()
385        .map(|s| s.registry.generation())
386        .unwrap_or(0)
387}
388
389/// Builds a [`ResolvedType`] for `type_name` out of a locally parsed
390/// `Ast` (the IDL-file path).
391fn resolve_from_ast(ast: &Arc<Ast>, type_name: &str) -> Result<ResolvedType, TypeResolveError> {
392    let dynamic_type = DynamicType::find(ast, type_name)
393        .map_err(|e| TypeResolveError::NotFound(format!("{type_name}: {e}")))?;
394    let root_id = dynamic_type.id();
395    let complete = zerodds_types::from_ast::complete_type_object(ast, root_id)
396        .map_err(|e| TypeResolveError::Model(format!("{e:?}")))?;
397    // Every other aggregate type the root's members reach also needs a
398    // CompleteTypeObject in the dependency closure — every named
399    // (non-primitive) type in the model, minus the root itself, is
400    // offered; `SerializedTypeSupport` only uses what it needs.
401    let mut dependencies = Vec::new();
402    for (id, _) in ast.types() {
403        if id == root_id {
404            continue;
405        }
406        if let Ok(dep) = zerodds_types::from_ast::complete_type_object(ast, id) {
407            dependencies.push(dep);
408        }
409    }
410    resolve_from_complete(type_name, complete, dependencies)
411}
412
413/// Shared tail: turns a `CompleteTypeObject` + its dependency closure
414/// into a [`ResolvedType`].
415///
416/// `keyed`/`extensibility` are read directly off the object — XTypes
417/// 1.3 §7.3.4.5 states a struct's/union's extensibility in its own
418/// `struct_flags`/`union_flags` (`IS_FINAL`/`IS_APPENDABLE`/
419/// `IS_MUTABLE`) and a member's `@key` in its `member_flags`
420/// (`IS_KEY`, §7.3.4.6) — never brute-forced. The model
421/// (`ModelFromTypeObjects`) is built first, and
422/// [`zerodds_ast_layout::keyhash::holder_keeps_only_keys`] /
423/// [`zerodds_ast_layout::keyhash::union_key_discriminator`] /
424/// [`DynamicType::extensibility`] read those exact bits back off it —
425/// the same public functions `SerializedTypeSupport`'s own (private)
426/// `from_decoded_objects` calls internally to derive `actual_keyed`/
427/// `actual_ext`, so this reads the object the identical way the
428/// constructor that validates it would.
429fn resolve_from_complete(
430    type_name: &str,
431    root: zerodds_types::CompleteTypeObject,
432    dependencies: Vec<zerodds_types::CompleteTypeObject>,
433) -> Result<ResolvedType, TypeResolveError> {
434    let root_bytes = TypeObject::Complete(root.clone())
435        .to_bytes_le()
436        .map_err(|e| TypeResolveError::Model(format!("re-encoding root TypeObject: {e:?}")))?;
437    let dep_bytes: Vec<Vec<u8>> = dependencies
438        .iter()
439        .map(|d| {
440            TypeObject::Complete(d.clone())
441                .to_bytes_le()
442                .map_err(|e| TypeResolveError::Model(format!("re-encoding dependency: {e:?}")))
443        })
444        .collect::<Result<_, _>>()?;
445
446    let mut registry = zerodds_types::resolve::TypeRegistry::new();
447    for dep in &dependencies {
448        registry
449            .insert_object(TypeObject::Complete(dep.clone()))
450            .map_err(|e| TypeResolveError::Model(format!("dependency hash: {e:?}")))?;
451    }
452    registry
453        .insert_object(TypeObject::Complete(root.clone()))
454        .map_err(|e| TypeResolveError::Model(format!("root hash: {e:?}")))?;
455    let mut model = zerodds_types::dynamic::ModelFromTypeObjects::new(&registry);
456    let root_id = model
457        .add_type_object(&TypeObject::Complete(root))
458        .map_err(|e| TypeResolveError::Model(format!("{e:?}")))?;
459    let ast = model.finish();
460
461    let dynamic_type = DynamicType::new(Arc::clone(&ast), root_id);
462    let keyed = zerodds_ast_layout::keyhash::holder_keeps_only_keys(&ast, root_id)
463        || zerodds_ast_layout::keyhash::union_key_discriminator(&ast, root_id).is_some();
464    let extensibility = match dynamic_type.extensibility() {
465        Some(zerodds_types::dynamic::ExtensibilityKind::Final) => 0,
466        Some(zerodds_types::dynamic::ExtensibilityKind::Appendable) => 1,
467        Some(zerodds_types::dynamic::ExtensibilityKind::Mutable) => 2,
468        None => {
469            return Err(TypeResolveError::Model(format!(
470                "`{type_name}`'s root has no aggregate (struct/union) extensibility"
471            )));
472        }
473    };
474
475    let support = SerializedTypeSupport::from_type_objects(
476        type_name,
477        &root_bytes,
478        &dep_bytes,
479        keyed,
480        extensibility,
481    )
482    .map_err(TypeResolveError::Model)?;
483
484    Ok(ResolvedType {
485        support,
486        dynamic_type,
487        keyed,
488    })
489}
490
491#[cfg(test)]
492#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
493mod tests {
494    use super::*;
495
496    fn write_idl(dir: &std::path::Path, text: &str) -> std::path::PathBuf {
497        let path = dir.join("t.idl");
498        std::fs::write(&path, text).unwrap();
499        path
500    }
501
502    fn write_xml(dir: &std::path::Path, text: &str) -> std::path::PathBuf {
503        let path = dir.join("t.xml");
504        std::fs::write(&path, text).unwrap();
505        path
506    }
507
508    #[test]
509    fn loads_a_keyed_struct_from_a_dds_xml_types_file() {
510        let dir = std::env::temp_dir().join(format!("zd-webdds-xmltypes-{}", std::process::id()));
511        std::fs::create_dir_all(&dir).unwrap();
512        let path = write_xml(
513            &dir,
514            r#"<types>
515                <struct name="Sample">
516                    <member name="id" type="int32" key="true"/>
517                    <member name="value" type="float64"/>
518                </struct>
519            </types>"#,
520        );
521        let reg = TypeRegistry::new();
522        let resolved = reg.load_xml_file(&path, "Sample").expect("resolved");
523        assert!(resolved.keyed);
524        assert_eq!(resolved.dynamic_type.member_count(), 2);
525        assert!(reg.get("Sample").is_some());
526        let _ = std::fs::remove_dir_all(&dir);
527    }
528
529    #[test]
530    fn dds_xml_types_file_with_an_enum_dependency_resolves() {
531        let dir = std::env::temp_dir().join(format!("zd-webdds-xmltypes2-{}", std::process::id()));
532        std::fs::create_dir_all(&dir).unwrap();
533        let path = write_xml(
534            &dir,
535            r#"<types>
536                <enum name="Color">
537                    <enumerator name="RED"/>
538                    <enumerator name="GREEN"/>
539                </enum>
540                <struct name="Pixel">
541                    <member name="id" type="int32" key="true"/>
542                    <member name="color" type="nonBasic" nonBasicTypeName="Color"/>
543                </struct>
544            </types>"#,
545        );
546        let reg = TypeRegistry::new();
547        let resolved = reg.load_xml_file(&path, "Pixel").expect("resolved");
548        assert!(resolved.keyed);
549        assert_eq!(resolved.dynamic_type.member_count(), 2);
550        let _ = std::fs::remove_dir_all(&dir);
551    }
552
553    #[test]
554    fn unknown_type_in_xml_file_is_reported() {
555        let dir = std::env::temp_dir().join(format!("zd-webdds-xmltypes3-{}", std::process::id()));
556        std::fs::create_dir_all(&dir).unwrap();
557        let path = write_xml(
558            &dir,
559            r#"<types><struct name="A"><member name="x" type="int32"/></struct></types>"#,
560        );
561        let reg = TypeRegistry::new();
562        let err = reg.load_xml_file(&path, "NoSuchType").unwrap_err();
563        assert!(matches!(err, TypeResolveError::NotFound(_)));
564        let _ = std::fs::remove_dir_all(&dir);
565    }
566
567    #[test]
568    fn loads_a_keyed_struct_from_an_idl_file() {
569        let dir = std::env::temp_dir().join(format!("zd-webdds-types-{}", std::process::id()));
570        std::fs::create_dir_all(&dir).unwrap();
571        let path = write_idl(
572            &dir,
573            "module M { @final struct Sample { @key long id; string name; }; };",
574        );
575        let reg = TypeRegistry::new();
576        let resolved = reg.load_idl_file(&path, "M::Sample").expect("resolved");
577        assert!(resolved.keyed);
578        assert_eq!(resolved.dynamic_type.member_count(), 2);
579        // Cached under the name.
580        assert!(reg.get("M::Sample").is_some());
581        let _ = std::fs::remove_dir_all(&dir);
582    }
583
584    #[test]
585    fn unknown_type_name_is_reported() {
586        let dir = std::env::temp_dir().join(format!("zd-webdds-types2-{}", std::process::id()));
587        std::fs::create_dir_all(&dir).unwrap();
588        let path = write_idl(&dir, "module M { @final struct A { long x; }; };");
589        let reg = TypeRegistry::new();
590        let err = reg.load_idl_file(&path, "M::NoSuchType").unwrap_err();
591        assert!(matches!(err, TypeResolveError::NotFound(_)));
592        let _ = std::fs::remove_dir_all(&dir);
593    }
594
595    #[test]
596    fn unkeyed_struct_roundtrip() {
597        let dir = std::env::temp_dir().join(format!("zd-webdds-types3-{}", std::process::id()));
598        std::fs::create_dir_all(&dir).unwrap();
599        let path = write_idl(
600            &dir,
601            "module M { @final struct Plain { long x; long y; }; };",
602        );
603        let reg = TypeRegistry::new();
604        let resolved = reg.load_idl_file(&path, "M::Plain").expect("resolved");
605        assert!(!resolved.keyed);
606        let _ = std::fs::remove_dir_all(&dir);
607    }
608
609    /// A second participant, which never read the IDL file, resolves
610    /// the topic's type purely from SEDP discovery + a real `getTypes`
611    /// round trip — proving `resolve_named` end to end, not just its
612    /// bookkeeping.
613    #[test]
614    fn resolve_named_discovers_a_type_never_loaded_locally() {
615        let dir = std::env::temp_dir().join(format!("zd-webdds-types-tl-{}", std::process::id()));
616        std::fs::create_dir_all(&dir).unwrap();
617        let path = write_idl(
618            &dir,
619            "module M { @final struct Tlq { @key long id; long value; }; };",
620        );
621
622        let lease = zerodds_test_domains::lease_domain();
623        let domain = lease.id();
624
625        // Side A: publishes the topic, with the type known locally.
626        let reg_a = TypeRegistry::new();
627        let resolved_a = reg_a.load_idl_file(&path, "M::Tlq").expect("resolve A");
628        let participant_a = zerodds_dcps::DomainParticipantFactory::instance()
629            .create_participant(domain, zerodds_dcps::DomainParticipantQos::default())
630            .expect("participant A");
631        let topic_a = participant_a
632            .create_serialized_topic("Tlq", zerodds_dcps::TopicQos::default(), resolved_a.support)
633            .expect("topic A");
634        let publisher_a = participant_a.create_publisher(zerodds_dcps::PublisherQos::default());
635        let _writer_a = publisher_a
636            .create_datawriter(&topic_a, zerodds_dcps::DataWriterQos::default())
637            .expect("writer A");
638
639        // Side B: never loads the IDL file — only discovers the type.
640        let participant_b = zerodds_dcps::DomainParticipantFactory::instance()
641            .create_participant(domain, zerodds_dcps::DomainParticipantQos::default())
642            .expect("participant B");
643        let subscriber_b = participant_b.create_subscriber(zerodds_dcps::SubscriberQos::default());
644        // A reader on the *typed* API needs its own type support to
645        // build; the point under test is that `reg_b` resolves the type
646        // without ever having read `path` — so `reg_b` uses a distinct
647        // registry from `reg_a`, and only `reg_b`'s resolution result is
648        // asserted on. The reader itself is not needed for that — SEDP
649        // discovers the type from the *writer*'s announcement alone.
650        let _ = &subscriber_b;
651
652        let reg_b = TypeRegistry::new();
653        let runtime_b = participant_b.runtime().expect("live runtime B").clone();
654        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
655        let mut resolved_b = None;
656        while resolved_b.is_none() && std::time::Instant::now() < deadline {
657            match reg_b.resolve_named(
658                &runtime_b,
659                "Tlq",
660                "M::Tlq",
661                std::time::Duration::from_secs(2),
662            ) {
663                Ok(r) => resolved_b = Some(r),
664                Err(TypeResolveError::NoDiscoveredEndpoint(_)) => {
665                    std::thread::sleep(std::time::Duration::from_millis(100));
666                }
667                Err(e) => panic!("resolve_named failed: {e}"),
668            }
669        }
670        let resolved_b = resolved_b.expect("type resolved via TypeLookup within 20s");
671        assert!(resolved_b.keyed);
672        assert_eq!(resolved_b.dynamic_type.member_count(), 2);
673
674        let _ = std::fs::remove_dir_all(&dir);
675    }
676}