Skip to main content

mabi_opcua/modeling/
mod.rs

1//! Canonical session-centric modeling surface for the OPC UA simulator.
2
3mod cache;
4mod codegen;
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use mabi_core::tags::Tags;
13use mabi_core::types::{AccessMode, Address, DataPointDef, DataType};
14use mabi_core::value::Value;
15use mabi_runtime::{ProtocolLaunchSpec, RuntimeExtensions};
16
17pub use self::cache::CompilationCacheReport;
18use self::cache::{
19    build_compilation_cache_key, build_import_cache_key, ImportCacheCounters, ModelingCache,
20};
21use self::codegen::{build_generated_type_catalog, render_generated_rust_module};
22use crate::config::{
23    MessageSecurityMode, OpcUaServerConfig, SecurityPolicy, TransportConnectionMode,
24    TransportProtocol,
25};
26use crate::error::{OpcUaError, OpcUaResult};
27use crate::nodes::base::{LocalizedText, QualifiedName};
28use crate::nodes::classes::{
29    DataTypeNode, MethodNode, ObjectNode, ObjectTypeNode, ReferenceTypeNode, VariableNode,
30    VariableTypeNode, ViewNode,
31};
32use crate::nodes::{AddressSpace, Reference, ReferenceDirection, ReferenceTypeId};
33use crate::sdk::subscription::SubscriptionDurabilityConfig;
34use crate::security::{
35    DeprecatedPolicyHandling, RoleMappingRule, SecurityAuditSinkConfig, SecurityManagerConfig,
36};
37use crate::types::{AccessLevel, NodeId, NodeIdType, Variant};
38
39const STANDARD_NAMESPACE_URI: &str = "http://opcfoundation.org/UA/";
40const DEFAULT_NAMESPACE_URI: &str = "urn:mabinogion:opcua:simulator";
41const DEFAULT_SERVER_NAME: &str = "Mabinogion OPC UA Simulator";
42const DEFAULT_ENDPOINT_PATH: &str = "/";
43
44/// Canonical file-backed config surface for the OPC UA simulator.
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct OpcUaSimulatorConfig {
47    #[serde(default)]
48    pub defaults: SimulatorDefaults,
49    #[serde(default)]
50    pub transports: BTreeMap<String, TransportDefinition>,
51    #[serde(default)]
52    pub security_profiles: BTreeMap<String, SecurityProfileDefinition>,
53    #[serde(default)]
54    pub nodesets: BTreeMap<String, NodeSetSource>,
55    #[serde(default)]
56    pub companion_packs: BTreeMap<String, CompanionPackDefinition>,
57    #[serde(default)]
58    pub models: BTreeMap<String, ModelDefinition>,
59    #[serde(default)]
60    pub devices: BTreeMap<String, DeviceDefinition>,
61    #[serde(default)]
62    pub sessions: BTreeMap<String, SessionDefinition>,
63    #[serde(default)]
64    pub presets: BTreeMap<String, PresetDefinition>,
65    #[serde(default)]
66    pub generated_types: GeneratedTypesConfig,
67}
68
69impl OpcUaSimulatorConfig {
70    /// Loads a simulator config from YAML, JSON, or TOML.
71    pub fn from_path(path: &Path) -> OpcUaResult<Self> {
72        let content = fs::read_to_string(path)?;
73        let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
74        Self::from_str_with_format(&content, extension, Some(path))
75    }
76
77    /// Parses a simulator config from the supplied content and format hint.
78    pub fn from_str_with_format(
79        content: &str,
80        format: &str,
81        base_path: Option<&Path>,
82    ) -> OpcUaResult<Self> {
83        let parsed: Self = match format {
84            "yaml" | "yml" => serde_yaml::from_str(content)
85                .map_err(|error| OpcUaError::Config(format!("invalid YAML config: {}", error)))?,
86            "json" => serde_json::from_str(content)
87                .map_err(|error| OpcUaError::Config(format!("invalid JSON config: {}", error)))?,
88            "toml" => toml::from_str(content)
89                .map_err(|error| OpcUaError::Config(format!("invalid TOML config: {}", error)))?,
90            other => {
91                return Err(OpcUaError::Config(format!(
92                    "unsupported config format: {}",
93                    other
94                )))
95            }
96        };
97        parsed.validate(base_path)?;
98        Ok(parsed)
99    }
100
101    /// Validates all named references and canonical compile-time invariants.
102    pub fn validate(&self, base_path: Option<&Path>) -> OpcUaResult<()> {
103        if self.sessions.is_empty() {
104            return Err(OpcUaError::Config(
105                "simulator config must define at least one session".into(),
106            ));
107        }
108
109        for (name, session) in &self.sessions {
110            if !self.transports.contains_key(&session.transport) {
111                return Err(OpcUaError::Config(format!(
112                    "session '{}' references unknown transport '{}'",
113                    name, session.transport
114                )));
115            }
116            if session.models.is_empty() && session.devices.is_empty() && session.preset.is_none() {
117                return Err(OpcUaError::Config(format!(
118                    "session '{}' must reference at least one model, device, or preset",
119                    name
120                )));
121            }
122            for model in &session.models {
123                if !self.models.contains_key(model) {
124                    return Err(OpcUaError::Config(format!(
125                        "session '{}' references unknown model '{}'",
126                        name, model
127                    )));
128                }
129            }
130            for device in &session.devices {
131                if !self.devices.contains_key(device) {
132                    return Err(OpcUaError::Config(format!(
133                        "session '{}' references unknown device '{}'",
134                        name, device
135                    )));
136                }
137            }
138            if let Some(preset) = &session.preset {
139                if !self.presets.contains_key(preset) {
140                    return Err(OpcUaError::Config(format!(
141                        "session '{}' references unknown preset '{}'",
142                        name, preset
143                    )));
144                }
145            }
146        }
147
148        for (name, transport) in &self.transports {
149            if let Some(profile_name) = &transport.security_profile {
150                resolve_security_profile_definition(self, profile_name).ok_or_else(|| {
151                    OpcUaError::Config(format!(
152                        "transport '{}' references unknown security profile '{}'",
153                        name, profile_name
154                    ))
155                })?;
156            }
157            if transport.protocol == TransportProtocol::Https
158                && (transport.certificate_path.is_none() || transport.private_key_path.is_none())
159            {
160                return Err(OpcUaError::Config(format!(
161                    "https transport '{}' requires certificate_path and private_key_path",
162                    name
163                )));
164            }
165            if transport.connection_mode == TransportConnectionMode::ReverseConnect {
166                if transport.protocol != TransportProtocol::OpcTcp {
167                    return Err(OpcUaError::Config(format!(
168                        "transport '{}' only supports reverse_connect with opc.tcp",
169                        name
170                    )));
171                }
172                if transport.reverse_connect_target.is_none() {
173                    return Err(OpcUaError::Config(format!(
174                        "transport '{}' requires reverse_connect_target when connection_mode is reverse_connect",
175                        name
176                    )));
177                }
178                if transport.retry_interval_ms == 0 {
179                    return Err(OpcUaError::Config(format!(
180                        "transport '{}' requires retry_interval_ms > 0 for reverse_connect",
181                        name
182                    )));
183                }
184            } else if transport.reverse_connect_target.is_some() {
185                return Err(OpcUaError::Config(format!(
186                    "transport '{}' sets reverse_connect_target but connection_mode is not reverse_connect",
187                    name
188                )));
189            }
190        }
191
192        for (name, model) in &self.models {
193            for nodeset in &model.nodesets {
194                let source = self.nodesets.get(nodeset).ok_or_else(|| {
195                    OpcUaError::Config(format!(
196                        "model '{}' references unknown nodeset '{}'",
197                        name, nodeset
198                    ))
199                })?;
200                validate_nodeset_source(nodeset, source, base_path)?;
201            }
202            for companion in &model.companions {
203                if !companion.name.is_empty() && !self.companion_packs.contains_key(&companion.name)
204                {
205                    return Err(OpcUaError::Config(format!(
206                        "model '{}' references unknown companion pack '{}'",
207                        name, companion.name
208                    )));
209                }
210            }
211            let mut overlay_ids = BTreeSet::new();
212            for overlay in &model.overlays {
213                let overlay_node_id = match overlay {
214                    OverlayNodeDefinition::Folder { node_id, .. }
215                    | OverlayNodeDefinition::Object { node_id, .. }
216                    | OverlayNodeDefinition::Variable { node_id, .. } => node_id,
217                };
218                if !overlay_ids.insert(overlay_node_id.clone()) {
219                    return Err(OpcUaError::Config(format!(
220                        "model '{}' contains duplicate overlay node '{}'",
221                        name, overlay_node_id
222                    )));
223                }
224            }
225        }
226
227        for (name, device) in &self.devices {
228            if !self.models.contains_key(&device.model) {
229                return Err(OpcUaError::Config(format!(
230                    "device '{}' references unknown model '{}'",
231                    name, device.model
232                )));
233            }
234        }
235
236        for name in self.sessions.keys() {
237            self.compile_session(name, base_path)?;
238        }
239
240        Ok(())
241    }
242
243    /// Compiles a named session into a stable launch spec and generated catalog.
244    pub fn compile_session(
245        &self,
246        name: &str,
247        base_path: Option<&Path>,
248    ) -> OpcUaResult<CompiledOpcUaSession> {
249        self.compile_session_with_report(name, base_path)
250            .map(|(compiled, _)| compiled)
251    }
252
253    /// Compiles a named session and reports cache usage for CLI inspection surfaces.
254    pub fn compile_session_with_report(
255        &self,
256        name: &str,
257        base_path: Option<&Path>,
258    ) -> OpcUaResult<(CompiledOpcUaSession, CompilationCacheReport)> {
259        let cache = ModelingCache::new();
260        let cache_key = build_compilation_cache_key(self, name, base_path)?;
261        if let Some(compiled) = cache.load_compiled_session(&cache_key) {
262            return Ok((
263                compiled,
264                CompilationCacheReport {
265                    compilation_hit: true,
266                    import_hits: 0,
267                    import_misses: 0,
268                    cache_dir: cache.root_display(),
269                },
270            ));
271        }
272
273        let session = self
274            .sessions
275            .get(name)
276            .ok_or_else(|| OpcUaError::Config(format!("unknown session '{}'", name)))?;
277        let transport = self.transports.get(&session.transport).ok_or_else(|| {
278            OpcUaError::Config(format!(
279                "session '{}' references unknown transport '{}'",
280                name, session.transport
281            ))
282        })?;
283        let resolved_security_profile = resolve_security_profile_definition(
284            self,
285            transport
286                .security_profile
287                .as_ref()
288                .or(self.defaults.security_profile.as_ref())
289                .map(String::as_str)
290                .unwrap_or("None"),
291        )
292        .ok_or_else(|| {
293            OpcUaError::Config(format!(
294                "session '{}' references unknown security profile",
295                name
296            ))
297        })?;
298
299        let mut synthetic_models = BTreeMap::new();
300        let mut synthetic_devices = BTreeMap::new();
301        if let Some(preset_name) = &session.preset {
302            let preset = self
303                .presets
304                .get(preset_name)
305                .ok_or_else(|| OpcUaError::Config(format!("unknown preset '{}'", preset_name)))?;
306            let (model_name, model, device_name, device) = preset.compile(
307                preset_name,
308                &self.defaults,
309                session.service_name.as_deref().unwrap_or(name),
310            );
311            synthetic_models.insert(model_name.clone(), model);
312            synthetic_devices.insert(device_name.clone(), device);
313        }
314
315        let mut ordered_model_names = Vec::new();
316        let mut seen_models = BTreeSet::new();
317        for model_name in &session.models {
318            if seen_models.insert(model_name.clone()) {
319                ordered_model_names.push(model_name.clone());
320            }
321        }
322        for device_name in &session.devices {
323            let device = self
324                .devices
325                .get(device_name)
326                .ok_or_else(|| OpcUaError::Config(format!("unknown device '{}'", device_name)))?;
327            if seen_models.insert(device.model.clone()) {
328                ordered_model_names.push(device.model.clone());
329            }
330        }
331        for device in synthetic_devices.values() {
332            if seen_models.insert(device.model.clone()) {
333                ordered_model_names.push(device.model.clone());
334            }
335        }
336
337        let mut namespace_table = vec![
338            STANDARD_NAMESPACE_URI.to_string(),
339            self.defaults.namespace_uri.clone(),
340        ];
341        let mut plan_sources = Vec::new();
342        let mut imported_nodesets = Vec::new();
343        let mut import_cache = ImportCacheCounters::default();
344
345        for model_name in &ordered_model_names {
346            let model = self
347                .models
348                .get(model_name)
349                .or_else(|| synthetic_models.get(model_name))
350                .ok_or_else(|| OpcUaError::Config(format!("unknown model '{}'", model_name)))?;
351            if let Some(namespace_uri) = &model.namespace_uri {
352                push_unique(&mut namespace_table, namespace_uri.clone());
353            }
354            for companion in &model.companions {
355                if let Some(namespace_uri) = &companion.namespace_uri {
356                    push_unique(&mut namespace_table, namespace_uri.clone());
357                }
358                if let Some(pack) = self.companion_packs.get(&companion.name) {
359                    if let Some(namespace_uri) = &pack.namespace_uri {
360                        push_unique(&mut namespace_table, namespace_uri.clone());
361                    }
362                }
363            }
364            for nodeset_name in &model.nodesets {
365                let source = self.nodesets.get(nodeset_name).ok_or_else(|| {
366                    OpcUaError::Config(format!(
367                        "model '{}' references unknown nodeset '{}'",
368                        model_name, nodeset_name
369                    ))
370                })?;
371                let imported =
372                    import_nodeset_source_cached(source, base_path, &cache, &mut import_cache)?;
373                for uri in imported.local_namespace_table.iter().skip(1) {
374                    push_unique(&mut namespace_table, uri.clone());
375                }
376                imported_nodesets.push((model_name.clone(), nodeset_name.clone(), imported));
377                plan_sources.push(format!("model:{} -> nodeset:{}", model_name, nodeset_name));
378            }
379            for companion in &model.companions {
380                if let Some(pack) = self.companion_packs.get(&companion.name) {
381                    for nodeset_name in &pack.nodesets {
382                        let source = self.nodesets.get(nodeset_name).ok_or_else(|| {
383                            OpcUaError::Config(format!(
384                                "companion pack '{}' references unknown nodeset '{}'",
385                                companion.name, nodeset_name
386                            ))
387                        })?;
388                        let imported = import_nodeset_source_cached(
389                            source,
390                            base_path,
391                            &cache,
392                            &mut import_cache,
393                        )?;
394                        for uri in imported.local_namespace_table.iter().skip(1) {
395                            push_unique(&mut namespace_table, uri.clone());
396                        }
397                        imported_nodesets.push((
398                            model_name.clone(),
399                            format!("companion:{}", nodeset_name),
400                            imported,
401                        ));
402                        plan_sources.push(format!(
403                            "model:{} -> companion:{} -> nodeset:{}",
404                            model_name, companion.name, nodeset_name
405                        ));
406                    }
407                }
408            }
409        }
410
411        let mut node_map = BTreeMap::<String, GeneratedNodeDefinition>::new();
412        let mut references = Vec::new();
413        let mut methods = Vec::new();
414        let mut events = Vec::new();
415
416        for (model_name, nodeset_name, imported) in &imported_nodesets {
417            let mapping = namespace_mapping(&imported.local_namespace_table, &namespace_table)?;
418            for node in &imported.nodes {
419                let remapped = node.remap_namespaces(&mapping)?;
420                let key = remapped.node_id().to_string();
421                if node_map.insert(key.clone(), remapped).is_some() {
422                    return Err(OpcUaError::Config(format!(
423                        "imported nodes collide on final NodeId '{}'",
424                        key
425                    )));
426                }
427            }
428            for reference in &imported.references {
429                references.push(reference.remap_namespaces(&mapping)?);
430            }
431            plan_sources.push(format!("import:{}:{}", model_name, nodeset_name));
432        }
433
434        for model_name in &ordered_model_names {
435            let model = self
436                .models
437                .get(model_name)
438                .or_else(|| synthetic_models.get(model_name))
439                .ok_or_else(|| OpcUaError::Config(format!("unknown model '{}'", model_name)))?;
440
441            for overlay in &model.overlays {
442                let node = overlay.compile()?;
443                let key = node.node_id().to_string();
444                if node_map.insert(key.clone(), node).is_some() {
445                    return Err(OpcUaError::Config(format!(
446                        "model '{}' overlay collides on final NodeId '{}'",
447                        model_name, key
448                    )));
449                }
450            }
451            for reference in &model.references {
452                references.push(reference.compile()?);
453            }
454            for method in &model.methods {
455                let (node, structural_reference) = method.compile()?;
456                let key = node.node_id().to_string();
457                if node_map.insert(key.clone(), node).is_some() {
458                    return Err(OpcUaError::Config(format!(
459                        "model '{}' method collides on final NodeId '{}'",
460                        model_name, key
461                    )));
462                }
463                if let Some(reference) = structural_reference {
464                    references.push(reference);
465                }
466                methods.push(method.clone());
467            }
468            events.extend(model.events.clone());
469        }
470
471        for reference in &references {
472            if !node_map.contains_key(&reference.source_node_id.to_string())
473                && !is_standard_node(&reference.source_node_id)
474            {
475                return Err(OpcUaError::Config(format!(
476                    "reference source '{}' does not exist",
477                    reference.source_node_id
478                )));
479            }
480            if !node_map.contains_key(&reference.target_node_id.to_string())
481                && !is_standard_node(&reference.target_node_id)
482            {
483                return Err(OpcUaError::Config(format!(
484                    "reference target '{}' does not exist",
485                    reference.target_node_id
486                )));
487            }
488        }
489
490        let mut compiled_devices = Vec::new();
491        if session.devices.is_empty() && synthetic_devices.is_empty() {
492            for model_name in &ordered_model_names {
493                let model = self
494                    .models
495                    .get(model_name)
496                    .or_else(|| synthetic_models.get(model_name))
497                    .ok_or_else(|| OpcUaError::Config(format!("unknown model '{}'", model_name)))?;
498                let auto_device = DeviceDefinition {
499                    model: model_name.clone(),
500                    node_bindings: Vec::new(),
501                    tags: Tags::new().with_tag("model", model_name.clone()),
502                    name: Some(model.display_name(model_name)),
503                };
504                compiled_devices.push(compile_device(
505                    &format!("device-{}", model_name),
506                    &auto_device,
507                    &node_map,
508                )?);
509            }
510        } else {
511            for device_name in &session.devices {
512                let device = self.devices.get(device_name).ok_or_else(|| {
513                    OpcUaError::Config(format!("unknown device '{}'", device_name))
514                })?;
515                compiled_devices.push(compile_device(device_name, device, &node_map)?);
516            }
517            for (device_name, device) in &synthetic_devices {
518                compiled_devices.push(compile_device(device_name, device, &node_map)?);
519            }
520        }
521
522        let mut point_ids = BTreeSet::new();
523        for device in &compiled_devices {
524            for point in &device.points {
525                if !point_ids.insert(point.point_id.clone()) {
526                    return Err(OpcUaError::Config(format!(
527                        "compiled session '{}' contains duplicate point id '{}'",
528                        name, point.point_id
529                    )));
530                }
531            }
532        }
533
534        let catalog = GeneratedNodeCatalog {
535            namespace_table: namespace_table.clone(),
536            namespace_plan: NamespaceCompilationPlan {
537                namespaces: namespace_table.clone(),
538                sources: plan_sources,
539                models: ordered_model_names.clone(),
540            },
541            nodes: node_map.into_values().collect(),
542            references,
543            type_tree_seeds: collect_type_tree_seeds(&namespace_table),
544            methods,
545            events,
546            point_bindings: compiled_devices
547                .iter()
548                .flat_map(|device| device.points.iter().cloned())
549                .collect(),
550        };
551        let generated_types = build_generated_type_catalog(
552            name,
553            &catalog,
554            &self.generated_types,
555            session.service_name.as_deref(),
556        );
557        let security = compile_security_profile(&resolved_security_profile);
558
559        let compiled_launch = OpcUaCompiledLaunchConfig {
560            session_name: name.to_string(),
561            server_config: build_server_config(
562                &self.defaults,
563                session,
564                transport,
565                &security,
566                base_path,
567            ),
568            catalog: catalog.clone(),
569            devices: compiled_devices.clone(),
570            control: session.control.clone(),
571            runtime: session.runtime.clone(),
572            generated_types: generated_types.clone(),
573            security,
574            readiness_timeout_ms: session
575                .readiness_timeout_ms
576                .or(self.defaults.readiness_timeout_ms),
577        };
578
579        let launch = ProtocolLaunchSpec {
580            protocol: "opcua".into(),
581            name: Some(
582                session
583                    .service_name
584                    .clone()
585                    .unwrap_or_else(|| name.to_string()),
586            ),
587            config: serde_json::to_value(&compiled_launch)
588                .map_err(|error| OpcUaError::Config(error.to_string()))?,
589        };
590
591        let compiled = CompiledOpcUaSession {
592            session_name: name.to_string(),
593            launch,
594            namespace_plan: catalog.namespace_plan.clone(),
595            catalog,
596            devices: compiled_devices,
597            control: session.control.clone(),
598            runtime: session.runtime.clone(),
599            generated_types,
600            security: compiled_launch.security.clone(),
601            readiness_timeout_ms: compiled_launch.readiness_timeout_ms,
602        };
603
604        let _ = cache.save_compiled_session(&cache_key, &compiled);
605
606        Ok((
607            compiled,
608            CompilationCacheReport {
609                compilation_hit: false,
610                import_hits: import_cache.hits,
611                import_misses: import_cache.misses,
612                cache_dir: cache.root_display(),
613            },
614        ))
615    }
616
617    /// Returns a stable inspection summary for CLI surfaces.
618    pub fn inspect_summary(&self) -> OpcUaConfigSummary {
619        OpcUaConfigSummary {
620            transports: self.transports.keys().cloned().collect(),
621            security_profiles: self.security_profiles.keys().cloned().collect(),
622            nodesets: self.nodesets.keys().cloned().collect(),
623            companion_packs: self.companion_packs.keys().cloned().collect(),
624            models: self.models.keys().cloned().collect(),
625            devices: self.devices.keys().cloned().collect(),
626            sessions: self
627                .sessions
628                .iter()
629                .map(|(name, session)| {
630                    let transport_protocol = self
631                        .transports
632                        .get(&session.transport)
633                        .map(|transport| transport.protocol)
634                        .unwrap_or_default();
635                    let transport_connection_mode = self
636                        .transports
637                        .get(&session.transport)
638                        .map(|transport| transport.connection_mode)
639                        .unwrap_or_default();
640                    OpcUaSessionSummary {
641                        name: name.clone(),
642                        transport: session.transport.clone(),
643                        transport_protocol,
644                        transport_connection_mode,
645                        models: session.models.clone(),
646                        devices: session.devices.clone(),
647                        preset: session.preset.clone(),
648                        service_name: session.service_name.clone(),
649                    }
650                })
651                .collect(),
652            presets: self.presets.keys().cloned().collect(),
653            generated_types_enabled: self.generated_types.enabled,
654        }
655    }
656}
657
658/// Loads a simulator config from the supplied path.
659pub fn load_simulator_config(path: &Path) -> OpcUaResult<OpcUaSimulatorConfig> {
660    OpcUaSimulatorConfig::from_path(path)
661}
662
663/// Compiles a named session from the supplied config.
664pub fn compile_session(
665    config: &OpcUaSimulatorConfig,
666    session_name: &str,
667    base_path: Option<&Path>,
668) -> OpcUaResult<CompiledOpcUaSession> {
669    config.compile_session(session_name, base_path)
670}
671
672/// Compiles a named session and reports cache usage.
673pub fn compile_session_with_report(
674    config: &OpcUaSimulatorConfig,
675    session_name: &str,
676    base_path: Option<&Path>,
677) -> OpcUaResult<(CompiledOpcUaSession, CompilationCacheReport)> {
678    config.compile_session_with_report(session_name, base_path)
679}
680
681/// Generates a deterministic Rust wrapper module from the canonical compilation graph.
682pub fn generate_types(
683    config: &OpcUaSimulatorConfig,
684    session_name: &str,
685    base_path: Option<&Path>,
686) -> OpcUaResult<GeneratedRustModule> {
687    generate_types_with_report(config, session_name, base_path).map(|(generated, _)| generated)
688}
689
690/// Generates deterministic Rust wrappers and reports cache usage.
691pub fn generate_types_with_report(
692    config: &OpcUaSimulatorConfig,
693    session_name: &str,
694    base_path: Option<&Path>,
695) -> OpcUaResult<(GeneratedRustModule, CompilationCacheReport)> {
696    let (compiled, report) = config.compile_session_with_report(session_name, base_path)?;
697    Ok((
698        render_generated_rust_module(&compiled.generated_types),
699        report,
700    ))
701}
702
703/// Returns the canonical schema summary for CLI inspection.
704pub fn schema_summary() -> OpcUaSchemaSummary {
705    OpcUaSchemaSummary {
706        kind: "opcua_simulator",
707        formats: vec!["yaml", "json", "toml"],
708        top_level_sections: vec![
709            SchemaSection::new(
710                "defaults",
711                false,
712                "Runtime-wide default namespace and server settings",
713            ),
714            SchemaSection::new("transports", true, "Named OPC UA endpoint definitions"),
715            SchemaSection::new(
716                "security_profiles",
717                false,
718                "Named security policy, audit, and role-mapping presets",
719            ),
720            SchemaSection::new("nodesets", false, "NodeSet2 import sources"),
721            SchemaSection::new(
722                "companion_packs",
723                false,
724                "Named companion-model bundles over NodeSet2 imports",
725            ),
726            SchemaSection::new("models", false, "Address-space composition and overlays"),
727            SchemaSection::new("devices", false, "Runtime-visible point bindings"),
728            SchemaSection::new("sessions", true, "Named runtime sessions"),
729            SchemaSection::new("presets", false, "Legacy convenience generation"),
730            SchemaSection::new(
731                "generated_types",
732                false,
733                "Deterministic Rust wrapper generation settings",
734            ),
735        ],
736        commands: vec![
737            "mabi inspect opcua-schema",
738            "mabi inspect opcua-config <file>",
739            "mabi validate opcua-config <file>",
740            "mabi serve opcua --config <file> --session <name>",
741            "mabi control opcua --config <file> --session <name> ...",
742            "mabi generate opcua-types --config <file> --session <name> --out <dir>",
743        ],
744        notes: vec![
745            "NodeSet2 imports are runtime-loaded and deterministic",
746            "Remote fetch and scripting are intentionally unsupported",
747            "Legacy builder and numeric serve paths compile into ephemeral sessions",
748        ],
749    }
750}
751
752/// Stable config inspection summary.
753pub fn inspect_summary(config: &OpcUaSimulatorConfig) -> OpcUaConfigSummary {
754    config.inspect_summary()
755}
756
757/// Default simulator-wide settings.
758#[derive(Debug, Clone, Serialize, Deserialize)]
759pub struct SimulatorDefaults {
760    #[serde(default = "default_namespace_uri")]
761    pub namespace_uri: String,
762    #[serde(default)]
763    pub readiness_timeout_ms: Option<u64>,
764    #[serde(default = "default_server_name")]
765    pub server_name: String,
766    #[serde(default)]
767    pub min_publishing_interval_ms: Option<u32>,
768    #[serde(default)]
769    pub security_profile: Option<String>,
770}
771
772impl Default for SimulatorDefaults {
773    fn default() -> Self {
774        Self {
775            namespace_uri: default_namespace_uri(),
776            readiness_timeout_ms: Some(5_000),
777            server_name: default_server_name(),
778            min_publishing_interval_ms: Some(100),
779            security_profile: Some("None".into()),
780        }
781    }
782}
783
784/// Named transport definition.
785#[derive(Debug, Clone, Serialize, Deserialize)]
786pub struct TransportDefinition {
787    #[serde(default)]
788    pub protocol: TransportProtocol,
789    #[serde(default)]
790    pub connection_mode: TransportConnectionMode,
791    #[serde(default = "default_bind_address")]
792    pub bind: String,
793    #[serde(default = "default_port")]
794    pub port: u16,
795    #[serde(default = "default_endpoint_path")]
796    pub endpoint_path: String,
797    #[serde(default)]
798    pub reverse_connect_target: Option<String>,
799    #[serde(default = "default_retry_interval_ms")]
800    pub retry_interval_ms: u64,
801    #[serde(default)]
802    pub security_profile: Option<String>,
803    #[serde(default)]
804    pub server_name: Option<String>,
805    #[serde(default)]
806    pub certificate_path: Option<PathBuf>,
807    #[serde(default)]
808    pub private_key_path: Option<PathBuf>,
809}
810
811impl Default for TransportDefinition {
812    fn default() -> Self {
813        Self {
814            protocol: TransportProtocol::OpcTcp,
815            connection_mode: TransportConnectionMode::Listener,
816            bind: default_bind_address(),
817            port: default_port(),
818            endpoint_path: default_endpoint_path(),
819            reverse_connect_target: None,
820            retry_interval_ms: default_retry_interval_ms(),
821            security_profile: Some("None".into()),
822            server_name: None,
823            certificate_path: None,
824            private_key_path: None,
825        }
826    }
827}
828
829/// Named security runtime preset referenced by transports.
830#[derive(Debug, Clone, Serialize, Deserialize)]
831pub struct SecurityProfileDefinition {
832    #[serde(default = "default_security_profile_policy")]
833    pub policy: String,
834    #[serde(default)]
835    pub mode: Option<MessageSecurityMode>,
836    #[serde(default)]
837    pub deprecated_policies: DeprecatedPolicyHandling,
838    #[serde(default)]
839    pub audit_sink: SecurityAuditSinkConfig,
840    #[serde(default)]
841    pub role_rules: Vec<RoleMappingRule>,
842    #[serde(default = "default_true")]
843    pub allow_trust_reload: bool,
844    #[serde(default = "default_true")]
845    pub allow_certificate_rotation: bool,
846}
847
848impl Default for SecurityProfileDefinition {
849    fn default() -> Self {
850        Self {
851            policy: default_security_profile_policy(),
852            mode: Some(MessageSecurityMode::None),
853            deprecated_policies: DeprecatedPolicyHandling::Allow,
854            audit_sink: SecurityAuditSinkConfig::default(),
855            role_rules: Vec::new(),
856            allow_trust_reload: true,
857            allow_certificate_rotation: true,
858        }
859    }
860}
861
862/// Named companion-model bundle.
863#[derive(Debug, Clone, Default, Serialize, Deserialize)]
864pub struct CompanionPackDefinition {
865    #[serde(default)]
866    pub namespace_uri: Option<String>,
867    #[serde(default)]
868    pub nodesets: Vec<String>,
869}
870
871/// Codegen configuration for deterministic typed wrappers.
872#[derive(Debug, Clone, Serialize, Deserialize)]
873pub struct GeneratedTypesConfig {
874    #[serde(default = "default_true")]
875    pub enabled: bool,
876    #[serde(default)]
877    pub module_name: Option<String>,
878}
879
880impl Default for GeneratedTypesConfig {
881    fn default() -> Self {
882        Self {
883            enabled: true,
884            module_name: None,
885        }
886    }
887}
888
889/// File-backed NodeSet2 source or embedded alias.
890#[derive(Debug, Clone, Serialize, Deserialize)]
891#[serde(tag = "kind", rename_all = "snake_case")]
892pub enum NodeSetSource {
893    File {
894        path: PathBuf,
895        #[serde(default)]
896        namespace_uri_override: Option<String>,
897    },
898    Embedded {
899        alias: String,
900        #[serde(default)]
901        namespace_uri_override: Option<String>,
902    },
903}
904
905/// Optional companion-model metadata reference.
906#[derive(Debug, Clone, Default, Serialize, Deserialize)]
907pub struct CompanionModelRef {
908    pub name: String,
909    #[serde(default)]
910    pub namespace_uri: Option<String>,
911}
912
913/// Address-space composition unit.
914#[derive(Debug, Clone, Default, Serialize, Deserialize)]
915pub struct ModelDefinition {
916    #[serde(default)]
917    pub nodesets: Vec<String>,
918    #[serde(default)]
919    pub namespace_uri: Option<String>,
920    #[serde(default)]
921    pub companions: Vec<CompanionModelRef>,
922    #[serde(default)]
923    pub overlays: Vec<OverlayNodeDefinition>,
924    #[serde(default)]
925    pub references: Vec<ReferenceDefinition>,
926    #[serde(default)]
927    pub methods: Vec<MethodDefinition>,
928    #[serde(default)]
929    pub events: Vec<EventDefinition>,
930}
931
932impl ModelDefinition {
933    fn display_name(&self, fallback: &str) -> String {
934        fallback.replace('-', " ")
935    }
936}
937
938/// Runtime-visible device bundle over a model.
939#[derive(Debug, Clone, Default, Serialize, Deserialize)]
940pub struct DeviceDefinition {
941    pub model: String,
942    #[serde(default)]
943    pub node_bindings: Vec<NodeBindingDefinition>,
944    #[serde(default, skip_serializing_if = "Tags::is_empty")]
945    pub tags: Tags,
946    #[serde(default)]
947    pub name: Option<String>,
948}
949
950/// Stable point binding onto a concrete OPC UA node.
951#[derive(Debug, Clone, Serialize, Deserialize)]
952pub struct NodeBindingDefinition {
953    pub point_id: String,
954    pub node_id: String,
955    #[serde(default)]
956    pub label: Option<String>,
957    #[serde(default)]
958    pub writable: Option<bool>,
959    #[serde(default)]
960    pub historizing: Option<bool>,
961    #[serde(default)]
962    pub sampling_interval_ms: Option<u32>,
963    #[serde(default)]
964    pub seed: Option<Value>,
965    #[serde(default, skip_serializing_if = "Tags::is_empty")]
966    pub tags: Tags,
967}
968
969/// Session-scoped control defaults.
970#[derive(Debug, Clone, Serialize, Deserialize)]
971pub struct SessionControlConfig {
972    #[serde(default = "default_true")]
973    pub allow_raw_node_access: bool,
974    #[serde(default)]
975    pub clear_persisted_subscriptions_on_reset: bool,
976}
977
978impl Default for SessionControlConfig {
979    fn default() -> Self {
980        Self {
981            allow_raw_node_access: true,
982            clear_persisted_subscriptions_on_reset: false,
983        }
984    }
985}
986
987/// Session-scoped runtime defaults.
988#[derive(Debug, Clone, Default, Serialize, Deserialize)]
989pub struct SessionRuntimeConfig {
990    #[serde(default)]
991    pub durability: SubscriptionDurabilityConfig,
992}
993
994/// Named execution unit.
995#[derive(Debug, Clone, Default, Serialize, Deserialize)]
996pub struct SessionDefinition {
997    pub transport: String,
998    #[serde(default)]
999    pub models: Vec<String>,
1000    #[serde(default)]
1001    pub devices: Vec<String>,
1002    #[serde(default)]
1003    pub preset: Option<String>,
1004    #[serde(default)]
1005    pub service_name: Option<String>,
1006    #[serde(default)]
1007    pub readiness_timeout_ms: Option<u64>,
1008    #[serde(default)]
1009    pub control: SessionControlConfig,
1010    #[serde(default)]
1011    pub runtime: SessionRuntimeConfig,
1012}
1013
1014/// Legacy convenience generation preset.
1015#[derive(Debug, Clone, Serialize, Deserialize)]
1016pub struct PresetDefinition {
1017    #[serde(default = "default_preset_nodes")]
1018    pub nodes: usize,
1019    #[serde(default = "default_preset_base_node")]
1020    pub base_node_id: u32,
1021    #[serde(default = "default_true")]
1022    pub writable: bool,
1023    #[serde(default)]
1024    pub historizing: bool,
1025    #[serde(default)]
1026    pub folder_name: Option<String>,
1027}
1028
1029impl Default for PresetDefinition {
1030    fn default() -> Self {
1031        Self {
1032            nodes: default_preset_nodes(),
1033            base_node_id: default_preset_base_node(),
1034            writable: true,
1035            historizing: false,
1036            folder_name: None,
1037        }
1038    }
1039}
1040
1041impl PresetDefinition {
1042    fn compile(
1043        &self,
1044        preset_name: &str,
1045        defaults: &SimulatorDefaults,
1046        service_name: &str,
1047    ) -> (String, ModelDefinition, String, DeviceDefinition) {
1048        let folder_id = format!("ns=1;s={}.folder", preset_name);
1049        let folder_name = self
1050            .folder_name
1051            .clone()
1052            .unwrap_or_else(|| format!("{} Model", service_name));
1053        let mut overlays = vec![OverlayNodeDefinition::Folder {
1054            node_id: folder_id.clone(),
1055            browse_name: folder_name.clone(),
1056            display_name: Some(folder_name.clone()),
1057            description: Some("Generated from legacy numeric OPC UA preset".into()),
1058        }];
1059        let mut bindings = Vec::new();
1060        for index in 0..self.nodes {
1061            let node_id = format!("ns=1;i={}", self.base_node_id + index as u32);
1062            let browse_name = format!("Variable_{}", index);
1063            overlays.push(OverlayNodeDefinition::Variable {
1064                node_id: node_id.clone(),
1065                browse_name: browse_name.clone(),
1066                display_name: Some(browse_name.clone()),
1067                description: Some("Generated variable".into()),
1068                data_type: Some("i=11".into()),
1069                value: Some(Value::F64(index as f64 * 0.1)),
1070                writable: self.writable,
1071                historizing: self.historizing,
1072                sampling_interval_ms: defaults.min_publishing_interval_ms,
1073            });
1074            bindings.push(NodeBindingDefinition {
1075                point_id: node_id.clone(),
1076                node_id,
1077                label: Some(browse_name),
1078                writable: Some(self.writable),
1079                historizing: Some(self.historizing),
1080                sampling_interval_ms: defaults.min_publishing_interval_ms,
1081                seed: None,
1082                tags: Tags::new(),
1083            });
1084        }
1085        let model_name = format!("preset-{}", preset_name);
1086        let device_name = format!("preset-device-{}", preset_name);
1087        (
1088            model_name.clone(),
1089            ModelDefinition {
1090                nodesets: Vec::new(),
1091                namespace_uri: Some(defaults.namespace_uri.clone()),
1092                companions: Vec::new(),
1093                overlays,
1094                references: vec![ReferenceDefinition {
1095                    source_node_id: NodeId::objects_folder().to_string(),
1096                    reference_type: ReferenceTypeId::Organizes,
1097                    target_node_id: folder_id.clone(),
1098                    direction: ReferenceDirection::Forward,
1099                }],
1100                methods: Vec::new(),
1101                events: Vec::new(),
1102            },
1103            device_name,
1104            DeviceDefinition {
1105                model: model_name,
1106                node_bindings: bindings,
1107                tags: Tags::new()
1108                    .with_label("generated")
1109                    .with_tag("preset", preset_name),
1110                name: Some(format!("{} Generated Device", service_name)),
1111            },
1112        )
1113    }
1114}
1115
1116/// Overlay node definition accepted by the canonical config surface.
1117#[derive(Debug, Clone, Serialize, Deserialize)]
1118#[serde(tag = "kind", rename_all = "snake_case")]
1119pub enum OverlayNodeDefinition {
1120    Folder {
1121        node_id: String,
1122        browse_name: String,
1123        #[serde(default)]
1124        display_name: Option<String>,
1125        #[serde(default)]
1126        description: Option<String>,
1127    },
1128    Object {
1129        node_id: String,
1130        browse_name: String,
1131        #[serde(default)]
1132        display_name: Option<String>,
1133        #[serde(default)]
1134        description: Option<String>,
1135        #[serde(default)]
1136        event_notifier: Option<u8>,
1137    },
1138    Variable {
1139        node_id: String,
1140        browse_name: String,
1141        #[serde(default)]
1142        display_name: Option<String>,
1143        #[serde(default)]
1144        description: Option<String>,
1145        #[serde(default)]
1146        data_type: Option<String>,
1147        #[serde(default)]
1148        value: Option<Value>,
1149        #[serde(default)]
1150        writable: bool,
1151        #[serde(default)]
1152        historizing: bool,
1153        #[serde(default)]
1154        sampling_interval_ms: Option<u32>,
1155    },
1156}
1157
1158impl OverlayNodeDefinition {
1159    fn compile(&self) -> OpcUaResult<GeneratedNodeDefinition> {
1160        match self {
1161            Self::Folder {
1162                node_id,
1163                browse_name,
1164                display_name,
1165                description,
1166            } => Ok(GeneratedNodeDefinition::Object {
1167                node_id: parse_node_id(node_id)?,
1168                browse_name: parse_browse_name(browse_name),
1169                display_name: localized_text(display_name.as_deref().unwrap_or(browse_name)),
1170                description: description.clone().map(localized_text_owned),
1171                event_notifier: 0,
1172                folder_like: true,
1173            }),
1174            Self::Object {
1175                node_id,
1176                browse_name,
1177                display_name,
1178                description,
1179                event_notifier,
1180            } => Ok(GeneratedNodeDefinition::Object {
1181                node_id: parse_node_id(node_id)?,
1182                browse_name: parse_browse_name(browse_name),
1183                display_name: localized_text(display_name.as_deref().unwrap_or(browse_name)),
1184                description: description.clone().map(localized_text_owned),
1185                event_notifier: event_notifier.unwrap_or(0),
1186                folder_like: false,
1187            }),
1188            Self::Variable {
1189                node_id,
1190                browse_name,
1191                display_name,
1192                description,
1193                data_type,
1194                value,
1195                writable,
1196                historizing,
1197                sampling_interval_ms,
1198            } => Ok(GeneratedNodeDefinition::Variable {
1199                node_id: parse_node_id(node_id)?,
1200                browse_name: parse_browse_name(browse_name),
1201                display_name: localized_text(display_name.as_deref().unwrap_or(browse_name)),
1202                description: description.clone().map(localized_text_owned),
1203                data_type: data_type
1204                    .as_ref()
1205                    .map(|node_id| parse_node_id(node_id))
1206                    .transpose()?
1207                    .unwrap_or_else(|| NodeId::numeric(0, 11)),
1208                value: value.clone().unwrap_or(Value::Null),
1209                writable: *writable,
1210                historizing: *historizing,
1211                sampling_interval_ms: *sampling_interval_ms,
1212            }),
1213        }
1214    }
1215}
1216
1217/// Structural reference addition.
1218#[derive(Debug, Clone, Serialize, Deserialize)]
1219pub struct ReferenceDefinition {
1220    pub source_node_id: String,
1221    pub reference_type: ReferenceTypeId,
1222    pub target_node_id: String,
1223    #[serde(default = "default_reference_direction")]
1224    pub direction: ReferenceDirection,
1225}
1226
1227impl ReferenceDefinition {
1228    fn compile(&self) -> OpcUaResult<CompiledNodeReference> {
1229        Ok(CompiledNodeReference {
1230            source_node_id: parse_node_id(&self.source_node_id)?,
1231            reference_type: self.reference_type,
1232            target_node_id: parse_node_id(&self.target_node_id)?,
1233            direction: self.direction,
1234        })
1235    }
1236}
1237
1238/// Structural method declaration.
1239#[derive(Debug, Clone, Serialize, Deserialize)]
1240pub struct MethodDefinition {
1241    pub node_id: String,
1242    pub parent_id: String,
1243    pub browse_name: String,
1244    #[serde(default)]
1245    pub display_name: Option<String>,
1246    #[serde(default = "default_true")]
1247    pub executable: bool,
1248}
1249
1250impl MethodDefinition {
1251    fn compile(&self) -> OpcUaResult<(GeneratedNodeDefinition, Option<CompiledNodeReference>)> {
1252        Ok((
1253            GeneratedNodeDefinition::Method {
1254                node_id: parse_node_id(&self.node_id)?,
1255                browse_name: parse_browse_name(&self.browse_name),
1256                display_name: localized_text(
1257                    self.display_name
1258                        .as_deref()
1259                        .unwrap_or(self.browse_name.as_str()),
1260                ),
1261                description: None,
1262                executable: self.executable,
1263            },
1264            Some(CompiledNodeReference {
1265                source_node_id: parse_node_id(&self.parent_id)?,
1266                reference_type: ReferenceTypeId::HasComponent,
1267                target_node_id: parse_node_id(&self.node_id)?,
1268                direction: ReferenceDirection::Forward,
1269            }),
1270        ))
1271    }
1272}
1273
1274/// Structural event declaration.
1275#[derive(Debug, Clone, Serialize, Deserialize)]
1276pub struct EventDefinition {
1277    pub event_type: String,
1278    #[serde(default)]
1279    pub source_node_id: Option<String>,
1280    #[serde(default)]
1281    pub description: Option<String>,
1282}
1283
1284/// Compiled namespace plan used by runtime materialization.
1285#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1286pub struct NamespaceCompilationPlan {
1287    pub namespaces: Vec<String>,
1288    pub sources: Vec<String>,
1289    pub models: Vec<String>,
1290}
1291
1292/// Compiled address-space catalog that the runtime can materialize directly.
1293#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1294pub struct GeneratedNodeCatalog {
1295    pub namespace_table: Vec<String>,
1296    pub namespace_plan: NamespaceCompilationPlan,
1297    pub nodes: Vec<GeneratedNodeDefinition>,
1298    pub references: Vec<CompiledNodeReference>,
1299    pub type_tree_seeds: Vec<TypeTreeSeed>,
1300    pub methods: Vec<MethodDefinition>,
1301    pub events: Vec<EventDefinition>,
1302    pub point_bindings: Vec<CompiledPointBinding>,
1303}
1304
1305/// Deterministic typed wrapper metadata generated from a compiled session catalog.
1306#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1307pub struct GeneratedTypeCatalog {
1308    pub session_name: String,
1309    pub module_name: String,
1310    pub entries: Vec<GeneratedTypeEntry>,
1311}
1312
1313/// One generated Rust-access wrapper entry.
1314#[derive(Debug, Clone, Serialize, Deserialize)]
1315pub struct GeneratedTypeEntry {
1316    pub rust_ident: String,
1317    pub node_id: String,
1318    pub browse_name: String,
1319    pub display_name: String,
1320    pub node_class: String,
1321}
1322
1323/// Deterministic Rust module output derived from the canonical compilation graph.
1324#[derive(Debug, Clone, Serialize, Deserialize)]
1325pub struct GeneratedRustModule {
1326    pub module_name: String,
1327    pub source: String,
1328    pub manifest_json: String,
1329}
1330
1331/// Resolved security profile compiled into a runtime-ready bundle.
1332#[derive(Debug, Clone, Serialize, Deserialize)]
1333pub struct CompiledSecurityProfile {
1334    pub name: String,
1335    pub policy: String,
1336    pub mode: MessageSecurityMode,
1337    pub deprecated_policies: DeprecatedPolicyHandling,
1338    pub audit_sink: SecurityAuditSinkConfig,
1339    pub role_rules: Vec<RoleMappingRule>,
1340    pub allow_trust_reload: bool,
1341    pub allow_certificate_rotation: bool,
1342    pub manager_config: SecurityManagerConfig,
1343}
1344
1345impl GeneratedNodeCatalog {
1346    /// Materializes the catalog into the provided address space.
1347    pub fn materialize(&self, address_space: &AddressSpace) -> OpcUaResult<()> {
1348        for namespace_uri in self.namespace_table.iter().skip(1) {
1349            address_space.register_namespace(namespace_uri);
1350        }
1351        for node in &self.nodes {
1352            node.insert_into(address_space);
1353        }
1354        for reference in &self.references {
1355            address_space.add_reference(reference.as_reference());
1356        }
1357        Ok(())
1358    }
1359
1360    /// Returns a stable namespace summary.
1361    pub fn namespace_summary(&self) -> Vec<String> {
1362        self.namespace_table.clone()
1363    }
1364}
1365
1366/// Typed compiled session shared by runtime and control flows.
1367#[derive(Debug, Clone, Serialize, Deserialize)]
1368pub struct CompiledOpcUaSession {
1369    pub session_name: String,
1370    pub launch: ProtocolLaunchSpec,
1371    pub namespace_plan: NamespaceCompilationPlan,
1372    pub catalog: GeneratedNodeCatalog,
1373    pub devices: Vec<CompiledDeviceDefinition>,
1374    pub control: SessionControlConfig,
1375    pub runtime: SessionRuntimeConfig,
1376    pub generated_types: GeneratedTypeCatalog,
1377    pub security: CompiledSecurityProfile,
1378    pub readiness_timeout_ms: Option<u64>,
1379}
1380
1381impl CompiledOpcUaSession {
1382    /// Runtime extensions currently remain empty for OPC UA.
1383    pub fn runtime_extensions(&self) -> RuntimeExtensions {
1384        RuntimeExtensions::default()
1385    }
1386}
1387
1388/// Serialized launch payload consumed by the runtime driver.
1389#[derive(Debug, Clone, Serialize, Deserialize)]
1390pub struct OpcUaCompiledLaunchConfig {
1391    pub session_name: String,
1392    pub server_config: OpcUaServerConfig,
1393    pub catalog: GeneratedNodeCatalog,
1394    pub devices: Vec<CompiledDeviceDefinition>,
1395    pub control: SessionControlConfig,
1396    pub runtime: SessionRuntimeConfig,
1397    pub generated_types: GeneratedTypeCatalog,
1398    pub security: CompiledSecurityProfile,
1399    pub readiness_timeout_ms: Option<u64>,
1400}
1401
1402/// Compiled device bundle for runtime registration.
1403#[derive(Debug, Clone, Serialize, Deserialize)]
1404pub struct CompiledDeviceDefinition {
1405    pub device_id: String,
1406    pub name: String,
1407    #[serde(default, skip_serializing_if = "Tags::is_empty")]
1408    pub tags: Tags,
1409    #[serde(default)]
1410    pub points: Vec<CompiledPointBinding>,
1411}
1412
1413/// Stable point binding metadata compiled into a session.
1414#[derive(Debug, Clone, Serialize, Deserialize)]
1415pub struct CompiledPointBinding {
1416    pub device_id: String,
1417    pub point_id: String,
1418    pub node_id: NodeId,
1419    pub node_class: String,
1420    pub display_name: String,
1421    pub browse_name: String,
1422    pub writable: bool,
1423    pub historizing: bool,
1424    pub sampling_interval_ms: Option<u32>,
1425    pub data_type: DataType,
1426    pub point_def: DataPointDef,
1427    #[serde(default, skip_serializing_if = "Tags::is_empty")]
1428    pub tags: Tags,
1429}
1430
1431/// Compiled reference entry.
1432#[derive(Debug, Clone, Serialize, Deserialize)]
1433pub struct CompiledNodeReference {
1434    pub source_node_id: NodeId,
1435    pub reference_type: ReferenceTypeId,
1436    pub target_node_id: NodeId,
1437    pub direction: ReferenceDirection,
1438}
1439
1440impl CompiledNodeReference {
1441    fn as_reference(&self) -> Reference {
1442        match self.direction {
1443            ReferenceDirection::Forward => Reference::forward(
1444                self.source_node_id.clone(),
1445                self.reference_type,
1446                self.target_node_id.clone(),
1447            ),
1448            ReferenceDirection::Inverse => Reference::inverse(
1449                self.source_node_id.clone(),
1450                self.reference_type,
1451                self.target_node_id.clone(),
1452            ),
1453        }
1454    }
1455
1456    fn remap_namespaces(&self, mapping: &BTreeMap<u16, u16>) -> OpcUaResult<Self> {
1457        Ok(Self {
1458            source_node_id: remap_node_id(&self.source_node_id, mapping)?,
1459            reference_type: self.reference_type,
1460            target_node_id: remap_node_id(&self.target_node_id, mapping)?,
1461            direction: self.direction,
1462        })
1463    }
1464}
1465
1466/// Type-tree seed metadata.
1467#[derive(Debug, Clone, Serialize, Deserialize)]
1468pub struct TypeTreeSeed {
1469    pub namespace_uri: String,
1470}
1471
1472/// Canonical compiled node representation.
1473#[derive(Debug, Clone, Serialize, Deserialize)]
1474#[serde(tag = "kind", rename_all = "snake_case")]
1475pub enum GeneratedNodeDefinition {
1476    Object {
1477        node_id: NodeId,
1478        browse_name: QualifiedName,
1479        display_name: LocalizedText,
1480        description: Option<LocalizedText>,
1481        event_notifier: u8,
1482        folder_like: bool,
1483    },
1484    Variable {
1485        node_id: NodeId,
1486        browse_name: QualifiedName,
1487        display_name: LocalizedText,
1488        description: Option<LocalizedText>,
1489        data_type: NodeId,
1490        value: Value,
1491        writable: bool,
1492        historizing: bool,
1493        sampling_interval_ms: Option<u32>,
1494    },
1495    Method {
1496        node_id: NodeId,
1497        browse_name: QualifiedName,
1498        display_name: LocalizedText,
1499        description: Option<LocalizedText>,
1500        executable: bool,
1501    },
1502    ObjectType {
1503        node_id: NodeId,
1504        browse_name: QualifiedName,
1505        display_name: LocalizedText,
1506    },
1507    VariableType {
1508        node_id: NodeId,
1509        browse_name: QualifiedName,
1510        display_name: LocalizedText,
1511        data_type: NodeId,
1512    },
1513    ReferenceType {
1514        node_id: NodeId,
1515        browse_name: QualifiedName,
1516        display_name: LocalizedText,
1517    },
1518    DataType {
1519        node_id: NodeId,
1520        browse_name: QualifiedName,
1521        display_name: LocalizedText,
1522    },
1523    View {
1524        node_id: NodeId,
1525        browse_name: QualifiedName,
1526        display_name: LocalizedText,
1527    },
1528}
1529
1530impl GeneratedNodeDefinition {
1531    pub fn node_id(&self) -> &NodeId {
1532        match self {
1533            Self::Object { node_id, .. }
1534            | Self::Variable { node_id, .. }
1535            | Self::Method { node_id, .. }
1536            | Self::ObjectType { node_id, .. }
1537            | Self::VariableType { node_id, .. }
1538            | Self::ReferenceType { node_id, .. }
1539            | Self::DataType { node_id, .. }
1540            | Self::View { node_id, .. } => node_id,
1541        }
1542    }
1543
1544    pub fn browse_name(&self) -> &QualifiedName {
1545        match self {
1546            Self::Object { browse_name, .. }
1547            | Self::Variable { browse_name, .. }
1548            | Self::Method { browse_name, .. }
1549            | Self::ObjectType { browse_name, .. }
1550            | Self::VariableType { browse_name, .. }
1551            | Self::ReferenceType { browse_name, .. }
1552            | Self::DataType { browse_name, .. }
1553            | Self::View { browse_name, .. } => browse_name,
1554        }
1555    }
1556
1557    pub fn display_name(&self) -> &LocalizedText {
1558        match self {
1559            Self::Object { display_name, .. }
1560            | Self::Variable { display_name, .. }
1561            | Self::Method { display_name, .. }
1562            | Self::ObjectType { display_name, .. }
1563            | Self::VariableType { display_name, .. }
1564            | Self::ReferenceType { display_name, .. }
1565            | Self::DataType { display_name, .. }
1566            | Self::View { display_name, .. } => display_name,
1567        }
1568    }
1569
1570    pub fn node_class_name(&self) -> &'static str {
1571        match self {
1572            Self::Object {
1573                folder_like: true, ..
1574            } => "folder",
1575            Self::Object { .. } => "object",
1576            Self::Variable { .. } => "variable",
1577            Self::Method { .. } => "method",
1578            Self::ObjectType { .. } => "object_type",
1579            Self::VariableType { .. } => "variable_type",
1580            Self::ReferenceType { .. } => "reference_type",
1581            Self::DataType { .. } => "data_type",
1582            Self::View { .. } => "view",
1583        }
1584    }
1585
1586    pub fn is_variable(&self) -> bool {
1587        matches!(self, Self::Variable { .. })
1588    }
1589
1590    fn remap_namespaces(&self, mapping: &BTreeMap<u16, u16>) -> OpcUaResult<Self> {
1591        Ok(match self {
1592            Self::Object {
1593                node_id,
1594                browse_name,
1595                display_name,
1596                description,
1597                event_notifier,
1598                folder_like,
1599            } => Self::Object {
1600                node_id: remap_node_id(node_id, mapping)?,
1601                browse_name: remap_qualified_name(browse_name, mapping),
1602                display_name: display_name.clone(),
1603                description: description.clone(),
1604                event_notifier: *event_notifier,
1605                folder_like: *folder_like,
1606            },
1607            Self::Variable {
1608                node_id,
1609                browse_name,
1610                display_name,
1611                description,
1612                data_type,
1613                value,
1614                writable,
1615                historizing,
1616                sampling_interval_ms,
1617            } => Self::Variable {
1618                node_id: remap_node_id(node_id, mapping)?,
1619                browse_name: remap_qualified_name(browse_name, mapping),
1620                display_name: display_name.clone(),
1621                description: description.clone(),
1622                data_type: remap_node_id(data_type, mapping)?,
1623                value: value.clone(),
1624                writable: *writable,
1625                historizing: *historizing,
1626                sampling_interval_ms: *sampling_interval_ms,
1627            },
1628            Self::Method {
1629                node_id,
1630                browse_name,
1631                display_name,
1632                description,
1633                executable,
1634            } => Self::Method {
1635                node_id: remap_node_id(node_id, mapping)?,
1636                browse_name: remap_qualified_name(browse_name, mapping),
1637                display_name: display_name.clone(),
1638                description: description.clone(),
1639                executable: *executable,
1640            },
1641            Self::ObjectType {
1642                node_id,
1643                browse_name,
1644                display_name,
1645            } => Self::ObjectType {
1646                node_id: remap_node_id(node_id, mapping)?,
1647                browse_name: remap_qualified_name(browse_name, mapping),
1648                display_name: display_name.clone(),
1649            },
1650            Self::VariableType {
1651                node_id,
1652                browse_name,
1653                display_name,
1654                data_type,
1655            } => Self::VariableType {
1656                node_id: remap_node_id(node_id, mapping)?,
1657                browse_name: remap_qualified_name(browse_name, mapping),
1658                display_name: display_name.clone(),
1659                data_type: remap_node_id(data_type, mapping)?,
1660            },
1661            Self::ReferenceType {
1662                node_id,
1663                browse_name,
1664                display_name,
1665            } => Self::ReferenceType {
1666                node_id: remap_node_id(node_id, mapping)?,
1667                browse_name: remap_qualified_name(browse_name, mapping),
1668                display_name: display_name.clone(),
1669            },
1670            Self::DataType {
1671                node_id,
1672                browse_name,
1673                display_name,
1674            } => Self::DataType {
1675                node_id: remap_node_id(node_id, mapping)?,
1676                browse_name: remap_qualified_name(browse_name, mapping),
1677                display_name: display_name.clone(),
1678            },
1679            Self::View {
1680                node_id,
1681                browse_name,
1682                display_name,
1683            } => Self::View {
1684                node_id: remap_node_id(node_id, mapping)?,
1685                browse_name: remap_qualified_name(browse_name, mapping),
1686                display_name: display_name.clone(),
1687            },
1688        })
1689    }
1690
1691    fn insert_into(&self, address_space: &AddressSpace) {
1692        match self {
1693            Self::Object {
1694                node_id,
1695                browse_name,
1696                display_name,
1697                description,
1698                event_notifier,
1699                ..
1700            } => {
1701                let mut node =
1702                    ObjectNode::new(node_id.clone(), browse_name.clone(), display_name.clone())
1703                        .with_event_notifier(*event_notifier);
1704                if let Some(description) = description {
1705                    node = node.with_description(description.clone());
1706                }
1707                address_space.insert_node(node);
1708            }
1709            Self::Variable {
1710                node_id,
1711                browse_name,
1712                display_name,
1713                description,
1714                data_type,
1715                value,
1716                writable,
1717                historizing,
1718                sampling_interval_ms,
1719            } => {
1720                let mut node = VariableNode::new(
1721                    node_id.clone(),
1722                    browse_name.clone(),
1723                    display_name.clone(),
1724                    data_type.clone(),
1725                    Variant::from(value.clone()),
1726                );
1727                if let Some(description) = description {
1728                    node = node.with_description(description.clone());
1729                }
1730                if *writable {
1731                    node = node.writable();
1732                }
1733                node = node.with_historizing(*historizing);
1734                if let Some(sampling_interval_ms) = sampling_interval_ms {
1735                    node = node.with_minimum_sampling_interval(*sampling_interval_ms as f64);
1736                }
1737                address_space.insert_node(node);
1738            }
1739            Self::Method {
1740                node_id,
1741                browse_name,
1742                display_name,
1743                description,
1744                executable,
1745            } => {
1746                let mut node =
1747                    MethodNode::new(node_id.clone(), browse_name.clone(), display_name.clone())
1748                        .with_executable(*executable);
1749                if let Some(description) = description {
1750                    node = node.with_description(description.clone());
1751                }
1752                address_space.insert_node(node);
1753            }
1754            Self::ObjectType {
1755                node_id,
1756                browse_name,
1757                display_name,
1758            } => {
1759                address_space.insert_node(ObjectTypeNode::new(
1760                    node_id.clone(),
1761                    browse_name.clone(),
1762                    display_name.clone(),
1763                ));
1764            }
1765            Self::VariableType {
1766                node_id,
1767                browse_name,
1768                display_name,
1769                data_type,
1770            } => {
1771                address_space.insert_node(VariableTypeNode::new(
1772                    node_id.clone(),
1773                    browse_name.clone(),
1774                    display_name.clone(),
1775                    data_type.clone(),
1776                ));
1777            }
1778            Self::ReferenceType {
1779                node_id,
1780                browse_name,
1781                display_name,
1782            } => {
1783                address_space.insert_node(ReferenceTypeNode::new(
1784                    node_id.clone(),
1785                    browse_name.clone(),
1786                    display_name.clone(),
1787                ));
1788            }
1789            Self::DataType {
1790                node_id,
1791                browse_name,
1792                display_name,
1793            } => {
1794                address_space.insert_node(DataTypeNode::new(
1795                    node_id.clone(),
1796                    browse_name.clone(),
1797                    display_name.clone(),
1798                ));
1799            }
1800            Self::View {
1801                node_id,
1802                browse_name,
1803                display_name,
1804            } => {
1805                address_space.insert_node(ViewNode::new(
1806                    node_id.clone(),
1807                    browse_name.clone(),
1808                    display_name.clone(),
1809                ));
1810            }
1811        }
1812    }
1813}
1814
1815/// CLI-oriented schema inspection surface.
1816#[derive(Debug, Clone, Serialize)]
1817pub struct OpcUaSchemaSummary {
1818    pub kind: &'static str,
1819    pub formats: Vec<&'static str>,
1820    pub top_level_sections: Vec<SchemaSection>,
1821    pub commands: Vec<&'static str>,
1822    pub notes: Vec<&'static str>,
1823}
1824
1825/// One section in the canonical schema surface.
1826#[derive(Debug, Clone, Serialize)]
1827pub struct SchemaSection {
1828    pub name: &'static str,
1829    pub required: bool,
1830    pub purpose: &'static str,
1831}
1832
1833impl SchemaSection {
1834    fn new(name: &'static str, required: bool, purpose: &'static str) -> Self {
1835        Self {
1836            name,
1837            required,
1838            purpose,
1839        }
1840    }
1841}
1842
1843/// Inspection summary for a parsed simulator config.
1844#[derive(Debug, Clone, Serialize)]
1845pub struct OpcUaConfigSummary {
1846    pub transports: Vec<String>,
1847    pub security_profiles: Vec<String>,
1848    pub nodesets: Vec<String>,
1849    pub companion_packs: Vec<String>,
1850    pub models: Vec<String>,
1851    pub devices: Vec<String>,
1852    pub sessions: Vec<OpcUaSessionSummary>,
1853    pub presets: Vec<String>,
1854    pub generated_types_enabled: bool,
1855}
1856
1857/// Summary of one named session.
1858#[derive(Debug, Clone, Serialize)]
1859pub struct OpcUaSessionSummary {
1860    pub name: String,
1861    pub transport: String,
1862    pub transport_protocol: TransportProtocol,
1863    pub transport_connection_mode: TransportConnectionMode,
1864    pub models: Vec<String>,
1865    pub devices: Vec<String>,
1866    pub preset: Option<String>,
1867    pub service_name: Option<String>,
1868}
1869
1870fn build_server_config(
1871    defaults: &SimulatorDefaults,
1872    session: &SessionDefinition,
1873    transport: &TransportDefinition,
1874    security: &CompiledSecurityProfile,
1875    base_path: Option<&Path>,
1876) -> OpcUaServerConfig {
1877    let server_name = transport
1878        .server_name
1879        .clone()
1880        .or_else(|| session.service_name.clone())
1881        .unwrap_or_else(|| defaults.server_name.clone());
1882    let endpoint_url = format!(
1883        "{}://{}:{}{}",
1884        transport.protocol.scheme(),
1885        transport.bind,
1886        transport.port,
1887        transport.endpoint_path
1888    );
1889    let mut config = OpcUaServerConfig {
1890        endpoint_url,
1891        endpoint_protocol: transport.protocol,
1892        connection_mode: transport.connection_mode,
1893        reverse_connect_target: transport.reverse_connect_target.clone(),
1894        retry_interval_ms: transport.retry_interval_ms,
1895        server_name,
1896        security_policy: security.policy.clone(),
1897        certificate_path: resolve_optional_path(base_path, transport.certificate_path.as_ref()),
1898        private_key_path: resolve_optional_path(base_path, transport.private_key_path.as_ref()),
1899        ..Default::default()
1900    };
1901    if let Some(min_publishing_interval_ms) = defaults.min_publishing_interval_ms {
1902        config.min_publishing_interval_ms = min_publishing_interval_ms;
1903    }
1904    config
1905}
1906
1907fn resolve_optional_path(base_path: Option<&Path>, path: Option<&PathBuf>) -> Option<PathBuf> {
1908    let path = path?;
1909    if path.is_absolute() {
1910        return Some(path.clone());
1911    }
1912    base_path
1913        .and_then(|base| base.parent())
1914        .map(|parent| parent.join(path))
1915        .or_else(|| Some(path.clone()))
1916}
1917
1918fn default_security_profile_policy() -> String {
1919    "None".into()
1920}
1921
1922fn default_retry_interval_ms() -> u64 {
1923    5_000
1924}
1925
1926fn resolve_security_profile_definition(
1927    config: &OpcUaSimulatorConfig,
1928    name: &str,
1929) -> Option<(String, SecurityProfileDefinition)> {
1930    if let Some(profile) = config.security_profiles.get(name) {
1931        return Some((name.to_string(), profile.clone()));
1932    }
1933
1934    builtin_security_profile(name).map(|profile| (name.to_string(), profile))
1935}
1936
1937fn builtin_security_profile(name: &str) -> Option<SecurityProfileDefinition> {
1938    let policy = match name {
1939        "None" => "None",
1940        "Basic128Rsa15" => "Basic128Rsa15",
1941        "Basic256" => "Basic256",
1942        "Basic256Sha256" => "Basic256Sha256",
1943        "Aes128Sha256RsaOaep" => "Aes128Sha256RsaOaep",
1944        "Aes256Sha256RsaPss" => "Aes256Sha256RsaPss",
1945        _ => return None,
1946    };
1947
1948    Some(SecurityProfileDefinition {
1949        policy: policy.into(),
1950        mode: Some(if policy == "None" {
1951            MessageSecurityMode::None
1952        } else {
1953            MessageSecurityMode::SignAndEncrypt
1954        }),
1955        ..Default::default()
1956    })
1957}
1958
1959fn compile_security_profile(
1960    (name, profile): &(String, SecurityProfileDefinition),
1961) -> CompiledSecurityProfile {
1962    let policy = profile.policy.clone();
1963    let mut manager_config = SecurityManagerConfig::default();
1964    manager_config.default_policy = parse_security_policy_name(&policy);
1965    manager_config.enabled_policies =
1966        vec![SecurityPolicy::None, parse_security_policy_name(&policy)];
1967    manager_config.reject_deprecated_policies = matches!(
1968        profile.deprecated_policies,
1969        DeprecatedPolicyHandling::Reject
1970    );
1971    manager_config.deprecated_policy_handling = profile.deprecated_policies;
1972    manager_config.audit_sink = profile.audit_sink.clone();
1973    manager_config.role_rules = profile.role_rules.clone();
1974
1975    CompiledSecurityProfile {
1976        name: name.clone(),
1977        policy,
1978        mode: profile.mode.unwrap_or(MessageSecurityMode::None),
1979        deprecated_policies: profile.deprecated_policies,
1980        audit_sink: profile.audit_sink.clone(),
1981        role_rules: profile.role_rules.clone(),
1982        allow_trust_reload: profile.allow_trust_reload,
1983        allow_certificate_rotation: profile.allow_certificate_rotation,
1984        manager_config,
1985    }
1986}
1987
1988fn parse_security_policy_name(value: &str) -> SecurityPolicy {
1989    match value {
1990        "Basic128Rsa15" => SecurityPolicy::Basic128Rsa15,
1991        "Basic256" => SecurityPolicy::Basic256,
1992        "Basic256Sha256" => SecurityPolicy::Basic256Sha256,
1993        "Aes128Sha256RsaOaep" => SecurityPolicy::Aes128Sha256RsaOaep,
1994        "Aes256Sha256RsaPss" => SecurityPolicy::Aes256Sha256RsaPss,
1995        _ => SecurityPolicy::None,
1996    }
1997}
1998
1999fn compile_device(
2000    device_name: &str,
2001    device: &DeviceDefinition,
2002    node_map: &BTreeMap<String, GeneratedNodeDefinition>,
2003) -> OpcUaResult<CompiledDeviceDefinition> {
2004    let mut bindings = if device.node_bindings.is_empty() {
2005        auto_bind_model(device_name, device, node_map)?
2006    } else {
2007        let mut bindings = Vec::new();
2008        for binding in &device.node_bindings {
2009            bindings.push(compile_binding(device_name, device, binding, node_map)?);
2010        }
2011        bindings
2012    };
2013
2014    bindings.sort_by(|left, right| left.point_id.cmp(&right.point_id));
2015
2016    Ok(CompiledDeviceDefinition {
2017        device_id: device_name.to_string(),
2018        name: device
2019            .name
2020            .clone()
2021            .unwrap_or_else(|| device_name.replace('-', " ")),
2022        tags: device.tags.clone(),
2023        points: bindings,
2024    })
2025}
2026
2027fn auto_bind_model(
2028    device_name: &str,
2029    device: &DeviceDefinition,
2030    node_map: &BTreeMap<String, GeneratedNodeDefinition>,
2031) -> OpcUaResult<Vec<CompiledPointBinding>> {
2032    let mut bindings = Vec::new();
2033    for node in node_map.values() {
2034        if let GeneratedNodeDefinition::Variable {
2035            node_id,
2036            display_name,
2037            browse_name,
2038            writable,
2039            historizing,
2040            sampling_interval_ms,
2041            data_type,
2042            ..
2043        } = node
2044        {
2045            let point_id = node_id.to_string();
2046            bindings.push(compiled_binding_from_node(
2047                device_name,
2048                &device.tags,
2049                &point_id,
2050                node,
2051                *writable,
2052                *historizing,
2053                *sampling_interval_ms,
2054                None,
2055                browse_name.name.as_str(),
2056                display_name.text.as_str(),
2057                data_type,
2058            )?);
2059        }
2060    }
2061    Ok(bindings)
2062}
2063
2064fn compile_binding(
2065    device_name: &str,
2066    device: &DeviceDefinition,
2067    binding: &NodeBindingDefinition,
2068    node_map: &BTreeMap<String, GeneratedNodeDefinition>,
2069) -> OpcUaResult<CompiledPointBinding> {
2070    let node = node_map.get(&binding.node_id).ok_or_else(|| {
2071        OpcUaError::Config(format!(
2072            "device '{}' references unknown node '{}'",
2073            device_name, binding.node_id
2074        ))
2075    })?;
2076    let GeneratedNodeDefinition::Variable {
2077        browse_name,
2078        display_name,
2079        writable,
2080        historizing,
2081        sampling_interval_ms,
2082        data_type,
2083        ..
2084    } = node
2085    else {
2086        return Err(OpcUaError::Config(format!(
2087            "device '{}' binding '{}' targets non-variable node '{}'",
2088            device_name, binding.point_id, binding.node_id
2089        )));
2090    };
2091    let merged_tags = device.tags.clone().merged_with(binding.tags.clone());
2092    compiled_binding_from_node(
2093        device_name,
2094        &merged_tags,
2095        &binding.point_id,
2096        node,
2097        binding.writable.unwrap_or(*writable),
2098        binding.historizing.unwrap_or(*historizing),
2099        binding.sampling_interval_ms.or(*sampling_interval_ms),
2100        binding.seed.clone(),
2101        binding
2102            .label
2103            .as_deref()
2104            .unwrap_or(browse_name.name.as_str()),
2105        display_name.text.as_str(),
2106        data_type,
2107    )
2108}
2109
2110fn compiled_binding_from_node(
2111    device_name: &str,
2112    tags: &Tags,
2113    point_id: &str,
2114    node: &GeneratedNodeDefinition,
2115    writable: bool,
2116    historizing: bool,
2117    sampling_interval_ms: Option<u32>,
2118    seed: Option<Value>,
2119    label: &str,
2120    display_name: &str,
2121    data_type: &NodeId,
2122) -> OpcUaResult<CompiledPointBinding> {
2123    let node_id = node.node_id().clone();
2124    let data_type_kind = map_data_type(data_type);
2125    let access = if writable {
2126        AccessMode::ReadWrite
2127    } else {
2128        AccessMode::ReadOnly
2129    };
2130    let mut point_def = DataPointDef::new(point_id, label, data_type_kind)
2131        .with_access(access)
2132        .with_address(Address::OpcUa {
2133            node_id: node_id.to_string(),
2134        });
2135    if let Some(seed) = seed {
2136        point_def.default_value = Some(seed);
2137    }
2138    if !matches!(node, GeneratedNodeDefinition::Variable { .. }) {
2139        return Err(OpcUaError::Config(format!(
2140            "point '{}' must bind to a variable node",
2141            point_id
2142        )));
2143    }
2144    Ok(CompiledPointBinding {
2145        device_id: device_name.to_string(),
2146        point_id: point_id.to_string(),
2147        node_id,
2148        node_class: node.node_class_name().to_string(),
2149        display_name: display_name.to_string(),
2150        browse_name: node.browse_name().name.clone(),
2151        writable,
2152        historizing,
2153        sampling_interval_ms,
2154        data_type: data_type_kind,
2155        point_def,
2156        tags: tags.clone(),
2157    })
2158}
2159
2160fn map_data_type(data_type: &NodeId) -> DataType {
2161    match data_type.as_numeric() {
2162        Some(1) => DataType::Bool,
2163        Some(2) => DataType::Int8,
2164        Some(3) => DataType::UInt8,
2165        Some(4) => DataType::Int16,
2166        Some(5) => DataType::UInt16,
2167        Some(6) => DataType::Int32,
2168        Some(7) => DataType::UInt32,
2169        Some(8) => DataType::Int64,
2170        Some(9) => DataType::UInt64,
2171        Some(10) => DataType::Float32,
2172        Some(11) => DataType::Float64,
2173        Some(12) => DataType::String,
2174        Some(13) => DataType::DateTime,
2175        Some(15) => DataType::ByteString,
2176        _ => DataType::Float64,
2177    }
2178}
2179
2180#[derive(Debug, Clone, Serialize, Deserialize)]
2181struct ImportedNodeSet {
2182    local_namespace_table: Vec<String>,
2183    nodes: Vec<GeneratedNodeDefinition>,
2184    references: Vec<CompiledNodeReference>,
2185}
2186
2187fn import_nodeset_source_cached(
2188    source: &NodeSetSource,
2189    base_path: Option<&Path>,
2190    cache: &ModelingCache,
2191    counters: &mut ImportCacheCounters,
2192) -> OpcUaResult<ImportedNodeSet> {
2193    let cache_key = build_import_cache_key(source, base_path)?;
2194    if let Some(imported) = cache.load_imported_nodeset(&cache_key) {
2195        counters.record_hit();
2196        return Ok(imported);
2197    }
2198
2199    let imported = import_nodeset_source_uncached(source, base_path)?;
2200    let _ = cache.save_imported_nodeset(&cache_key, &imported);
2201    counters.record_miss();
2202    Ok(imported)
2203}
2204
2205fn import_nodeset_source_uncached(
2206    source: &NodeSetSource,
2207    base_path: Option<&Path>,
2208) -> OpcUaResult<ImportedNodeSet> {
2209    match source {
2210        NodeSetSource::File {
2211            path,
2212            namespace_uri_override,
2213        } => {
2214            let resolved = resolve_path(base_path, path);
2215            let xml = fs::read_to_string(&resolved)?;
2216            import_nodeset_xml(&xml, namespace_uri_override.as_ref())
2217        }
2218        NodeSetSource::Embedded {
2219            alias,
2220            namespace_uri_override,
2221        } => import_embedded_nodeset(alias, namespace_uri_override.as_ref()),
2222    }
2223}
2224
2225fn validate_nodeset_source(
2226    name: &str,
2227    source: &NodeSetSource,
2228    base_path: Option<&Path>,
2229) -> OpcUaResult<()> {
2230    match source {
2231        NodeSetSource::File { path, .. } => {
2232            let resolved = resolve_path(base_path, path);
2233            if !resolved.exists() {
2234                return Err(OpcUaError::Config(format!(
2235                    "nodeset '{}' path '{}' does not exist",
2236                    name,
2237                    resolved.display()
2238                )));
2239            }
2240        }
2241        NodeSetSource::Embedded { alias, .. } => {
2242            if !matches!(alias.as_str(), "minimal" | "demo" | "base_simulation") {
2243                return Err(OpcUaError::Config(format!(
2244                    "nodeset '{}' references unsupported embedded alias '{}'",
2245                    name, alias
2246                )));
2247            }
2248        }
2249    }
2250    Ok(())
2251}
2252
2253fn import_embedded_nodeset(
2254    alias: &str,
2255    namespace_uri_override: Option<&String>,
2256) -> OpcUaResult<ImportedNodeSet> {
2257    let namespace_uri = namespace_uri_override
2258        .cloned()
2259        .unwrap_or_else(|| format!("urn:mabinogion:opcua:embedded:{}", alias));
2260    match alias {
2261        "minimal" | "demo" | "base_simulation" => Ok(ImportedNodeSet {
2262            local_namespace_table: vec![STANDARD_NAMESPACE_URI.to_string(), namespace_uri],
2263            nodes: vec![
2264                GeneratedNodeDefinition::Object {
2265                    node_id: NodeId::string(1, format!("embedded/{}", alias)),
2266                    browse_name: QualifiedName::new(1, format!("Embedded{}", alias)),
2267                    display_name: localized_text(&format!("Embedded {}", alias)),
2268                    description: Some(localized_text("Embedded NodeSet2 alias")),
2269                    event_notifier: 0,
2270                    folder_like: false,
2271                },
2272                GeneratedNodeDefinition::Variable {
2273                    node_id: NodeId::string(1, format!("embedded/{}/value", alias)),
2274                    browse_name: QualifiedName::new(1, "Value"),
2275                    display_name: localized_text("Value"),
2276                    description: Some(localized_text("Embedded demo variable")),
2277                    data_type: NodeId::numeric(0, 11),
2278                    value: Value::F64(0.0),
2279                    writable: true,
2280                    historizing: false,
2281                    sampling_interval_ms: Some(100),
2282                },
2283            ],
2284            references: vec![CompiledNodeReference {
2285                source_node_id: NodeId::string(1, format!("embedded/{}", alias)),
2286                reference_type: ReferenceTypeId::HasComponent,
2287                target_node_id: NodeId::string(1, format!("embedded/{}/value", alias)),
2288                direction: ReferenceDirection::Forward,
2289            }],
2290        }),
2291        _ => Err(OpcUaError::Config(format!(
2292            "unsupported embedded NodeSet alias '{}'",
2293            alias
2294        ))),
2295    }
2296}
2297
2298fn import_nodeset_xml(
2299    xml: &str,
2300    namespace_uri_override: Option<&String>,
2301) -> OpcUaResult<ImportedNodeSet> {
2302    let namespace_uris = parse_namespace_uris(xml, namespace_uri_override);
2303    let mut nodes = Vec::new();
2304    let mut references = Vec::new();
2305
2306    for tag in [
2307        "UAObject",
2308        "UAVariable",
2309        "UAMethod",
2310        "UAObjectType",
2311        "UAVariableType",
2312        "UAReferenceType",
2313        "UADataType",
2314        "UAView",
2315    ] {
2316        for element in collect_xml_elements(xml, tag) {
2317            let (node, node_references) = parse_nodeset_element(tag, &element)?;
2318            nodes.push(node);
2319            references.extend(node_references);
2320        }
2321    }
2322
2323    Ok(ImportedNodeSet {
2324        local_namespace_table: namespace_uris,
2325        nodes,
2326        references,
2327    })
2328}
2329
2330fn parse_nodeset_element(
2331    tag: &str,
2332    element: &XmlElement,
2333) -> OpcUaResult<(GeneratedNodeDefinition, Vec<CompiledNodeReference>)> {
2334    let node_id = parse_node_id(required_attr(element, "NodeId")?)?;
2335    let browse_name = parse_browse_name(required_attr(element, "BrowseName")?);
2336    let display_name = localized_text(
2337        element
2338            .text_of("DisplayName")
2339            .unwrap_or(browse_name.name.clone()),
2340    );
2341    let description = element.text_of("Description").map(localized_text_owned);
2342    let references = parse_reference_elements(node_id.clone(), element)?;
2343
2344    let node = match tag {
2345        "UAObject" => GeneratedNodeDefinition::Object {
2346            node_id,
2347            browse_name,
2348            display_name,
2349            description,
2350            event_notifier: parse_u8_attr(element.attr("EventNotifier")).unwrap_or(0),
2351            folder_like: false,
2352        },
2353        "UAVariable" => GeneratedNodeDefinition::Variable {
2354            node_id,
2355            browse_name,
2356            display_name,
2357            description,
2358            data_type: element
2359                .attr("DataType")
2360                .map(parse_node_id)
2361                .transpose()?
2362                .unwrap_or_else(|| NodeId::numeric(0, 11)),
2363            value: parse_value_element(element.value_xml()).unwrap_or(Value::Null),
2364            writable: parse_access_level(element.attr("AccessLevel")).can_write(),
2365            historizing: parse_bool_attr(element.attr("Historizing")).unwrap_or(false),
2366            sampling_interval_ms: parse_f64_attr(element.attr("MinimumSamplingInterval"))
2367                .map(|value| value.max(0.0) as u32),
2368        },
2369        "UAMethod" => GeneratedNodeDefinition::Method {
2370            node_id,
2371            browse_name,
2372            display_name,
2373            description,
2374            executable: parse_bool_attr(element.attr("Executable")).unwrap_or(true),
2375        },
2376        "UAObjectType" => GeneratedNodeDefinition::ObjectType {
2377            node_id,
2378            browse_name,
2379            display_name,
2380        },
2381        "UAVariableType" => GeneratedNodeDefinition::VariableType {
2382            node_id,
2383            browse_name,
2384            display_name,
2385            data_type: element
2386                .attr("DataType")
2387                .map(parse_node_id)
2388                .transpose()?
2389                .unwrap_or_else(|| NodeId::numeric(0, 11)),
2390        },
2391        "UAReferenceType" => GeneratedNodeDefinition::ReferenceType {
2392            node_id,
2393            browse_name,
2394            display_name,
2395        },
2396        "UADataType" => GeneratedNodeDefinition::DataType {
2397            node_id,
2398            browse_name,
2399            display_name,
2400        },
2401        "UAView" => GeneratedNodeDefinition::View {
2402            node_id,
2403            browse_name,
2404            display_name,
2405        },
2406        other => {
2407            return Err(OpcUaError::Config(format!(
2408                "unsupported NodeSet element '{}'",
2409                other
2410            )))
2411        }
2412    };
2413
2414    Ok((node, references))
2415}
2416
2417fn parse_reference_elements(
2418    source_node_id: NodeId,
2419    element: &XmlElement,
2420) -> OpcUaResult<Vec<CompiledNodeReference>> {
2421    let Some(references_block) = element.child_xml("References") else {
2422        return Ok(Vec::new());
2423    };
2424    let mut references = Vec::new();
2425    for reference in collect_xml_elements(references_block, "Reference") {
2426        let reference_type = parse_reference_type(required_attr(&reference, "ReferenceType")?)?;
2427        let target_node_id = parse_node_id(reference.inner.trim())?;
2428        let direction = if reference.attr("IsForward") == Some("false") {
2429            ReferenceDirection::Inverse
2430        } else {
2431            ReferenceDirection::Forward
2432        };
2433        references.push(CompiledNodeReference {
2434            source_node_id: source_node_id.clone(),
2435            reference_type,
2436            target_node_id,
2437            direction,
2438        });
2439    }
2440    Ok(references)
2441}
2442
2443fn parse_namespace_uris(xml: &str, override_uri: Option<&String>) -> Vec<String> {
2444    let mut table = vec![STANDARD_NAMESPACE_URI.to_string()];
2445    if let Some(block) = child_xml_block(xml, "NamespaceUris") {
2446        for element in collect_xml_elements(block, "Uri") {
2447            let value = element.inner.trim();
2448            if !value.is_empty() {
2449                table.push(value.to_string());
2450            }
2451        }
2452    }
2453    if let Some(override_uri) = override_uri {
2454        if table.len() == 1 {
2455            table.push(override_uri.clone());
2456        } else if let Some(slot) = table.get_mut(1) {
2457            *slot = override_uri.clone();
2458        }
2459    }
2460    table
2461}
2462
2463fn collect_type_tree_seeds(namespace_table: &[String]) -> Vec<TypeTreeSeed> {
2464    namespace_table
2465        .iter()
2466        .cloned()
2467        .map(|namespace_uri| TypeTreeSeed { namespace_uri })
2468        .collect()
2469}
2470
2471fn namespace_mapping(local: &[String], global: &[String]) -> OpcUaResult<BTreeMap<u16, u16>> {
2472    let mut mapping = BTreeMap::new();
2473    for (index, uri) in local.iter().enumerate() {
2474        let global_index = global
2475            .iter()
2476            .position(|candidate| candidate == uri)
2477            .ok_or_else(|| {
2478                OpcUaError::Config(format!(
2479                    "missing namespace URI '{}' during compilation",
2480                    uri
2481                ))
2482            })?;
2483        mapping.insert(index as u16, global_index as u16);
2484    }
2485    Ok(mapping)
2486}
2487
2488fn remap_node_id(node_id: &NodeId, mapping: &BTreeMap<u16, u16>) -> OpcUaResult<NodeId> {
2489    let namespace = mapping
2490        .get(&node_id.namespace())
2491        .copied()
2492        .unwrap_or(node_id.namespace());
2493    Ok(match node_id.identifier() {
2494        NodeIdType::Numeric(value) => NodeId::numeric(namespace, *value),
2495        NodeIdType::String(value) => NodeId::string(namespace, value.clone()),
2496        NodeIdType::Guid(value) => NodeId::guid(namespace, *value),
2497        NodeIdType::ByteString(value) => NodeId::byte_string(namespace, value.clone()),
2498    })
2499}
2500
2501fn remap_qualified_name(
2502    browse_name: &QualifiedName,
2503    mapping: &BTreeMap<u16, u16>,
2504) -> QualifiedName {
2505    QualifiedName::new(
2506        mapping
2507            .get(&browse_name.namespace_index)
2508            .copied()
2509            .unwrap_or(browse_name.namespace_index),
2510        browse_name.name.clone(),
2511    )
2512}
2513
2514fn parse_node_id(value: &str) -> OpcUaResult<NodeId> {
2515    value
2516        .parse::<NodeId>()
2517        .map_err(|error| OpcUaError::InvalidNodeId(error.to_string()))
2518}
2519
2520fn parse_browse_name(value: &str) -> QualifiedName {
2521    if let Some((namespace, name)) = value.split_once(':') {
2522        if let Ok(namespace_index) = namespace.parse::<u16>() {
2523            return QualifiedName::new(namespace_index, name);
2524        }
2525    }
2526    QualifiedName::new(0, value)
2527}
2528
2529fn parse_reference_type(value: &str) -> OpcUaResult<ReferenceTypeId> {
2530    if let Ok(node_id) = parse_node_id(value) {
2531        if let Some(reference_type) = ReferenceTypeId::from_node_id(&node_id) {
2532            return Ok(reference_type);
2533        }
2534    }
2535    match value {
2536        "Organizes" => Ok(ReferenceTypeId::Organizes),
2537        "HasComponent" => Ok(ReferenceTypeId::HasComponent),
2538        "HasProperty" => Ok(ReferenceTypeId::HasProperty),
2539        "HasSubtype" => Ok(ReferenceTypeId::HasSubtype),
2540        "HasTypeDefinition" => Ok(ReferenceTypeId::HasTypeDefinition),
2541        "HasNotifier" => Ok(ReferenceTypeId::HasNotifier),
2542        "HasEventSource" => Ok(ReferenceTypeId::HasEventSource),
2543        other => Err(OpcUaError::Config(format!(
2544            "unsupported reference type '{}'",
2545            other
2546        ))),
2547    }
2548}
2549
2550fn parse_value_element(xml: Option<&str>) -> Option<Value> {
2551    let value_xml = xml?;
2552    for tag in [
2553        "Boolean",
2554        "Double",
2555        "Float",
2556        "Int16",
2557        "UInt16",
2558        "Int32",
2559        "UInt32",
2560        "Int64",
2561        "UInt64",
2562        "String",
2563        "DateTime",
2564        "ByteString",
2565    ] {
2566        if let Some(element) = collect_xml_elements(value_xml, tag).into_iter().next() {
2567            let raw = element.inner.trim();
2568            return match tag {
2569                "Boolean" => raw.parse::<bool>().ok().map(Value::Bool),
2570                "Double" | "Float" => raw.parse::<f64>().ok().map(Value::F64),
2571                "Int16" | "Int32" | "Int64" => raw.parse::<i64>().ok().map(Value::I64),
2572                "UInt16" | "UInt32" | "UInt64" => raw.parse::<u64>().ok().map(Value::U64),
2573                "String" => Some(Value::String(raw.to_string())),
2574                "DateTime" => Some(Value::String(raw.to_string())),
2575                "ByteString" => Some(Value::Bytes(raw.as_bytes().to_vec())),
2576                _ => None,
2577            };
2578        }
2579    }
2580    None
2581}
2582
2583fn parse_access_level(value: Option<&str>) -> AccessLevel {
2584    value
2585        .and_then(|raw| raw.parse::<u8>().ok())
2586        .map(AccessLevel::from_raw)
2587        .unwrap_or(AccessLevel::CURRENT_READ)
2588}
2589
2590fn parse_bool_attr(value: Option<&str>) -> Option<bool> {
2591    value.map(|value| value.eq_ignore_ascii_case("true") || value == "1")
2592}
2593
2594fn parse_u8_attr(value: Option<&str>) -> Option<u8> {
2595    value.and_then(|value| value.parse::<u8>().ok())
2596}
2597
2598fn parse_f64_attr(value: Option<&str>) -> Option<f64> {
2599    value.and_then(|value| value.parse::<f64>().ok())
2600}
2601
2602fn localized_text(text: impl Into<String>) -> LocalizedText {
2603    LocalizedText::new("en-US", text.into())
2604}
2605
2606fn localized_text_owned(text: impl Into<String>) -> LocalizedText {
2607    LocalizedText::new("en-US", text.into())
2608}
2609
2610fn push_unique(values: &mut Vec<String>, value: String) {
2611    if !values.iter().any(|existing| existing == &value) {
2612        values.push(value);
2613    }
2614}
2615
2616fn resolve_path(base_path: Option<&Path>, path: &Path) -> PathBuf {
2617    if path.is_absolute() {
2618        return path.to_path_buf();
2619    }
2620    match base_path.and_then(Path::parent) {
2621        Some(parent) => parent.join(path),
2622        None => path.to_path_buf(),
2623    }
2624}
2625
2626fn required_attr<'a>(element: &'a XmlElement, name: &str) -> OpcUaResult<&'a str> {
2627    element.attr(name).ok_or_else(|| {
2628        OpcUaError::Config(format!(
2629            "NodeSet element '{}' is missing required attribute '{}'",
2630            element.tag, name
2631        ))
2632    })
2633}
2634
2635fn is_standard_node(node_id: &NodeId) -> bool {
2636    node_id.namespace() == 0
2637}
2638
2639fn default_namespace_uri() -> String {
2640    DEFAULT_NAMESPACE_URI.to_string()
2641}
2642
2643fn default_server_name() -> String {
2644    DEFAULT_SERVER_NAME.to_string()
2645}
2646
2647fn default_bind_address() -> String {
2648    "0.0.0.0".to_string()
2649}
2650
2651fn default_port() -> u16 {
2652    4840
2653}
2654
2655fn default_endpoint_path() -> String {
2656    DEFAULT_ENDPOINT_PATH.to_string()
2657}
2658
2659fn default_preset_nodes() -> usize {
2660    100
2661}
2662
2663fn default_preset_base_node() -> u32 {
2664    1000
2665}
2666
2667fn default_true() -> bool {
2668    true
2669}
2670
2671fn default_reference_direction() -> ReferenceDirection {
2672    ReferenceDirection::Forward
2673}
2674
2675#[derive(Debug, Clone)]
2676struct XmlElement {
2677    tag: String,
2678    attrs: BTreeMap<String, String>,
2679    inner: String,
2680}
2681
2682impl XmlElement {
2683    fn attr(&self, name: &str) -> Option<&str> {
2684        self.attrs.get(name).map(String::as_str)
2685    }
2686
2687    fn text_of(&self, tag: &str) -> Option<String> {
2688        collect_xml_elements(&self.inner, tag)
2689            .into_iter()
2690            .next()
2691            .map(|element| element.inner.trim().to_string())
2692    }
2693
2694    fn child_xml(&self, tag: &str) -> Option<&str> {
2695        child_xml_block(&self.inner, tag)
2696    }
2697
2698    fn value_xml(&self) -> Option<&str> {
2699        self.child_xml("Value")
2700    }
2701}
2702
2703fn collect_xml_elements(xml: &str, tag: &str) -> Vec<XmlElement> {
2704    let mut elements = Vec::new();
2705    let mut position = 0;
2706    let open = format!("<{}", tag);
2707    let close = format!("</{}>", tag);
2708
2709    while let Some(relative_start) = xml[position..].find(&open) {
2710        let start = position + relative_start;
2711        let Some(relative_end) = xml[start..].find('>') else {
2712            break;
2713        };
2714        let end = start + relative_end;
2715        let open_tag = &xml[start + 1..end];
2716        let self_closing = open_tag.trim_end().ends_with('/');
2717        let attrs = parse_xml_attributes(
2718            open_tag
2719                .strip_prefix(tag)
2720                .unwrap_or(open_tag)
2721                .trim()
2722                .trim_end_matches('/'),
2723        );
2724
2725        if self_closing {
2726            elements.push(XmlElement {
2727                tag: tag.to_string(),
2728                attrs,
2729                inner: String::new(),
2730            });
2731            position = end + 1;
2732            continue;
2733        }
2734
2735        let search_start = end + 1;
2736        let Some(relative_close_start) = xml[search_start..].find(&close) else {
2737            break;
2738        };
2739        let close_start = search_start + relative_close_start;
2740        elements.push(XmlElement {
2741            tag: tag.to_string(),
2742            attrs,
2743            inner: xml[search_start..close_start].to_string(),
2744        });
2745        position = close_start + close.len();
2746    }
2747
2748    elements
2749}
2750
2751fn child_xml_block<'a>(xml: &'a str, tag: &str) -> Option<&'a str> {
2752    let open = format!("<{}>", tag);
2753    let close = format!("</{}>", tag);
2754    let start = xml.find(&open)? + open.len();
2755    let end = xml[start..].find(&close)? + start;
2756    Some(&xml[start..end])
2757}
2758
2759fn parse_xml_attributes(input: &str) -> BTreeMap<String, String> {
2760    let mut attrs = BTreeMap::new();
2761    let bytes = input.as_bytes();
2762    let mut index = 0;
2763    while index < bytes.len() {
2764        while index < bytes.len() && bytes[index].is_ascii_whitespace() {
2765            index += 1;
2766        }
2767        if index >= bytes.len() {
2768            break;
2769        }
2770        let key_start = index;
2771        while index < bytes.len() && !bytes[index].is_ascii_whitespace() && bytes[index] != b'=' {
2772            index += 1;
2773        }
2774        let key = input[key_start..index].trim();
2775        while index < bytes.len() && (bytes[index].is_ascii_whitespace() || bytes[index] == b'=') {
2776            index += 1;
2777        }
2778        if index >= bytes.len() || bytes[index] != b'"' {
2779            break;
2780        }
2781        index += 1;
2782        let value_start = index;
2783        while index < bytes.len() && bytes[index] != b'"' {
2784            index += 1;
2785        }
2786        if index <= bytes.len() {
2787            attrs.insert(key.to_string(), input[value_start..index].to_string());
2788        }
2789        index += 1;
2790    }
2791    attrs
2792}
2793
2794#[cfg(test)]
2795mod tests {
2796    use super::*;
2797
2798    use tempfile::NamedTempFile;
2799
2800    fn sample_nodeset() -> String {
2801        r#"
2802        <UANodeSet>
2803          <NamespaceUris>
2804            <Uri>urn:example:model</Uri>
2805          </NamespaceUris>
2806          <UAObject NodeId="ns=1;s=Machine" BrowseName="1:Machine">
2807            <DisplayName>Machine</DisplayName>
2808            <References>
2809              <Reference ReferenceType="Organizes">i=85</Reference>
2810            </References>
2811          </UAObject>
2812          <UAVariable NodeId="ns=1;s=Machine.Temperature" BrowseName="1:Temperature" DataType="i=11" AccessLevel="3" Historizing="true">
2813            <DisplayName>Temperature</DisplayName>
2814            <Value><Double>21.5</Double></Value>
2815            <References>
2816              <Reference ReferenceType="HasComponent">ns=1;s=Machine</Reference>
2817            </References>
2818          </UAVariable>
2819        </UANodeSet>
2820        "#
2821        .to_string()
2822    }
2823
2824    #[test]
2825    fn schema_summary_lists_canonical_sections() {
2826        let summary = schema_summary();
2827        assert_eq!(summary.kind, "opcua_simulator");
2828        assert!(summary
2829            .top_level_sections
2830            .iter()
2831            .any(|section| section.name == "nodesets"));
2832        assert!(summary
2833            .top_level_sections
2834            .iter()
2835            .any(|section| section.name == "security_profiles"));
2836        assert!(summary
2837            .top_level_sections
2838            .iter()
2839            .any(|section| section.name == "generated_types"));
2840    }
2841
2842    #[test]
2843    fn imported_nodeset_parses_nodes_and_references() {
2844        let imported = import_nodeset_xml(&sample_nodeset(), None).unwrap();
2845        assert_eq!(imported.local_namespace_table.len(), 2);
2846        assert_eq!(imported.nodes.len(), 2);
2847        assert_eq!(imported.references.len(), 2);
2848    }
2849
2850    #[test]
2851    fn config_compiles_file_backed_session() {
2852        let nodeset = NamedTempFile::new().unwrap();
2853        fs::write(nodeset.path(), sample_nodeset()).unwrap();
2854
2855        let config = OpcUaSimulatorConfig {
2856            transports: BTreeMap::from([(
2857                "main".into(),
2858                TransportDefinition {
2859                    protocol: TransportProtocol::OpcTcp,
2860                    connection_mode: TransportConnectionMode::Listener,
2861                    bind: "127.0.0.1".into(),
2862                    port: 4840,
2863                    endpoint_path: "/sim".into(),
2864                    reverse_connect_target: None,
2865                    retry_interval_ms: 5_000,
2866                    security_profile: Some("None".into()),
2867                    server_name: None,
2868                    certificate_path: None,
2869                    private_key_path: None,
2870                },
2871            )]),
2872            nodesets: BTreeMap::from([(
2873                "demo".into(),
2874                NodeSetSource::File {
2875                    path: nodeset.path().to_path_buf(),
2876                    namespace_uri_override: None,
2877                },
2878            )]),
2879            models: BTreeMap::from([(
2880                "machine".into(),
2881                ModelDefinition {
2882                    nodesets: vec!["demo".into()],
2883                    ..Default::default()
2884                },
2885            )]),
2886            sessions: BTreeMap::from([(
2887                "demo".into(),
2888                SessionDefinition {
2889                    transport: "main".into(),
2890                    models: vec!["machine".into()],
2891                    devices: Vec::new(),
2892                    preset: None,
2893                    service_name: Some("opcua-demo".into()),
2894                    readiness_timeout_ms: Some(1_000),
2895                    control: SessionControlConfig::default(),
2896                    runtime: SessionRuntimeConfig::default(),
2897                },
2898            )]),
2899            ..Default::default()
2900        };
2901
2902        let compiled = config
2903            .compile_session("demo", Some(nodeset.path()))
2904            .unwrap();
2905        assert_eq!(compiled.session_name, "demo");
2906        assert!(!compiled.catalog.nodes.is_empty());
2907        assert!(!compiled.devices.is_empty());
2908        assert_eq!(compiled.launch.protocol, "opcua");
2909    }
2910
2911    #[test]
2912    fn legacy_preset_generates_ephemeral_model() {
2913        let config = OpcUaSimulatorConfig {
2914            transports: BTreeMap::from([("main".into(), TransportDefinition::default())]),
2915            presets: BTreeMap::from([("legacy".into(), PresetDefinition::default())]),
2916            sessions: BTreeMap::from([(
2917                "legacy".into(),
2918                SessionDefinition {
2919                    transport: "main".into(),
2920                    preset: Some("legacy".into()),
2921                    service_name: Some("legacy-service".into()),
2922                    ..Default::default()
2923                },
2924            )]),
2925            ..Default::default()
2926        };
2927
2928        let compiled = config.compile_session("legacy", None).unwrap();
2929        assert!(!compiled.catalog.nodes.is_empty());
2930        assert!(!compiled.devices.is_empty());
2931    }
2932
2933    #[test]
2934    fn generate_types_renders_stable_module() {
2935        let config = OpcUaSimulatorConfig {
2936            transports: BTreeMap::from([("main".into(), TransportDefinition::default())]),
2937            presets: BTreeMap::from([("legacy".into(), PresetDefinition::default())]),
2938            sessions: BTreeMap::from([(
2939                "legacy".into(),
2940                SessionDefinition {
2941                    transport: "main".into(),
2942                    preset: Some("legacy".into()),
2943                    service_name: Some("legacy-service".into()),
2944                    ..Default::default()
2945                },
2946            )]),
2947            generated_types: GeneratedTypesConfig {
2948                enabled: true,
2949                module_name: Some("legacy_types".into()),
2950            },
2951            ..Default::default()
2952        };
2953
2954        let generated = generate_types(&config, "legacy", None).unwrap();
2955        assert_eq!(generated.module_name, "legacy_types");
2956        assert!(generated.source.contains("pub mod legacy_types"));
2957        assert!(generated
2958            .manifest_json
2959            .contains("\"module_name\": \"legacy_types\""));
2960    }
2961
2962    #[test]
2963    fn compile_session_reports_cache_hits() {
2964        let session_name = format!("legacy-{}", uuid::Uuid::new_v4());
2965        let config = OpcUaSimulatorConfig {
2966            transports: BTreeMap::from([("main".into(), TransportDefinition::default())]),
2967            presets: BTreeMap::from([("legacy".into(), PresetDefinition::default())]),
2968            sessions: BTreeMap::from([(
2969                session_name.clone(),
2970                SessionDefinition {
2971                    transport: "main".into(),
2972                    preset: Some("legacy".into()),
2973                    service_name: Some("legacy-service".into()),
2974                    ..Default::default()
2975                },
2976            )]),
2977            ..Default::default()
2978        };
2979
2980        let (_, first) = compile_session_with_report(&config, &session_name, None).unwrap();
2981        let (_, second) = compile_session_with_report(&config, &session_name, None).unwrap();
2982        assert!(!first.compilation_hit);
2983        assert!(second.compilation_hit);
2984    }
2985}