Skip to main content

mabi_modbus/
simulator.rs

1//! Session-centric simulator configuration and DX-oriented inspection helpers.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::net::SocketAddr;
5use std::path::Path;
6use std::sync::Arc;
7
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Value as JsonValue};
10
11use mabi_runtime::{ProtocolLaunchSpec, RuntimeExtensions};
12
13use crate::behavior::BehaviorLayer;
14use crate::error::{ModbusError, ModbusResult};
15use crate::fault_injection::{
16    ExtraDataMode, FaultConfig, FaultInjectionConfig, FaultTarget, FaultTypeConfig,
17    PartialFrameMode, TruncationMode,
18};
19use crate::profile::{DatastoreKind, GeneratedProfilePreset, SimulatorProfile};
20use crate::rtu::{PerformancePreset as RtuPerformancePreset, RtuServerConfig};
21use crate::tcp::PerformancePreset as TcpPerformancePreset;
22use mabi_core::types::{DataType, ModbusRegisterType};
23
24/// Canonical session-centric config surface for the Modbus simulator.
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26pub struct ModbusSimulatorConfig {
27    #[serde(default)]
28    pub defaults: SimulatorDefaults,
29    #[serde(default)]
30    pub transports: BTreeMap<String, TransportDefinition>,
31    #[serde(default)]
32    pub datastores: BTreeMap<String, DatastoreDefinition>,
33    #[serde(default)]
34    pub devices: BTreeMap<String, DeviceBundleDefinition>,
35    #[serde(default)]
36    pub sessions: BTreeMap<String, SessionDefinition>,
37    #[serde(default)]
38    pub presets: BTreeMap<String, GeneratedPresetDefinition>,
39    #[serde(default)]
40    pub actions: BTreeMap<String, ActionDefinition>,
41    #[serde(default)]
42    pub behaviors: BTreeMap<String, BehaviorDefinition>,
43    #[serde(default)]
44    pub response_profiles: BTreeMap<String, ResponseProfileDefinition>,
45}
46
47impl ModbusSimulatorConfig {
48    /// Loads a simulator config from a YAML, JSON, or TOML file.
49    pub fn from_path(path: &Path) -> ModbusResult<Self> {
50        let content = std::fs::read_to_string(path)?;
51        let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
52        Self::from_str_with_format(&content, extension)
53    }
54
55    /// Parses a config from the supplied content and file-format hint.
56    pub fn from_str_with_format(content: &str, format: &str) -> ModbusResult<Self> {
57        let parsed: Self = match format {
58            "yaml" | "yml" => serde_yaml::from_str(content)
59                .map_err(|error| ModbusError::Config(format!("invalid YAML config: {}", error)))?,
60            "json" => serde_json::from_str(content)
61                .map_err(|error| ModbusError::Config(format!("invalid JSON config: {}", error)))?,
62            "toml" => toml::from_str(content)
63                .map_err(|error| ModbusError::Config(format!("invalid TOML config: {}", error)))?,
64            other => {
65                return Err(ModbusError::Config(format!(
66                    "unsupported config format: {}",
67                    other
68                )))
69            }
70        };
71        parsed.validate()?;
72        Ok(parsed)
73    }
74
75    /// Validates references and compile-time invariants across the config.
76    pub fn validate(&self) -> ModbusResult<()> {
77        if self.sessions.is_empty() {
78            return Err(ModbusError::Config(
79                "simulator config must define at least one session".into(),
80            ));
81        }
82
83        for (name, session) in &self.sessions {
84            if !self.transports.contains_key(&session.transport) {
85                return Err(ModbusError::Config(format!(
86                    "session '{}' references unknown transport '{}'",
87                    name, session.transport
88                )));
89            }
90            if session.devices.is_empty() && session.preset.is_none() {
91                return Err(ModbusError::Config(format!(
92                    "session '{}' must reference at least one device bundle or preset",
93                    name
94                )));
95            }
96            for device in &session.devices {
97                if !self.devices.contains_key(device) {
98                    return Err(ModbusError::Config(format!(
99                        "session '{}' references unknown device bundle '{}'",
100                        name, device
101                    )));
102                }
103            }
104            if let Some(preset) = &session.preset {
105                if !self.presets.contains_key(preset) {
106                    return Err(ModbusError::Config(format!(
107                        "session '{}' references unknown preset '{}'",
108                        name, preset
109                    )));
110                }
111            }
112            if let Some(active) = &session.active_fault_preset {
113                if !session.fault_presets.contains_key(active) {
114                    return Err(ModbusError::Config(format!(
115                        "session '{}' references unknown fault preset '{}'",
116                        name, active
117                    )));
118                }
119            }
120            if let Some(active) = &session.active_response_profile {
121                if !self.response_profiles.contains_key(active) {
122                    return Err(ModbusError::Config(format!(
123                        "session '{}' references unknown response profile '{}'",
124                        name, active
125                    )));
126                }
127            }
128            if let Some(active) = &session.active_behavior_set {
129                if !session.behavior_sets.contains_key(active) {
130                    return Err(ModbusError::Config(format!(
131                        "session '{}' references unknown behavior set '{}'",
132                        name, active
133                    )));
134                }
135            }
136
137            let mut unit_ids = BTreeSet::new();
138            let compiled_profile = self.compile_profile(session)?;
139            for unit in &compiled_profile.units {
140                if !unit_ids.insert(unit.unit_id) {
141                    return Err(ModbusError::Config(format!(
142                        "session '{}' contains duplicate unit id {}",
143                        name, unit.unit_id
144                    )));
145                }
146
147                let mut point_ids = BTreeSet::new();
148                for point in &unit.points {
149                    if !point_ids.insert(point.id.clone()) {
150                        return Err(ModbusError::Config(format!(
151                            "session '{}' contains duplicate point id '{}' in unit {}",
152                            name, point.id, unit.unit_id
153                        )));
154                    }
155                }
156            }
157
158            for device_name in &session.devices {
159                let bundle = self.devices.get(device_name).ok_or_else(|| {
160                    ModbusError::Config(format!("unknown device bundle '{}'", device_name))
161                })?;
162                for unit in &bundle.units {
163                    let point_ids = unit
164                        .points
165                        .iter()
166                        .map(|point| point.id.as_str())
167                        .collect::<BTreeSet<_>>();
168                    for binding in &unit.action_bindings {
169                        if !point_ids.contains(binding.point_id.as_str()) {
170                            return Err(ModbusError::Config(format!(
171                                "unit {} references unknown point '{}' in action bindings",
172                                unit.unit_id, binding.point_id
173                            )));
174                        }
175                        for action in &binding.bindings {
176                            if !self.actions.contains_key(&action.action) {
177                                return Err(ModbusError::Config(format!(
178                                    "unit {} references unknown action '{}'",
179                                    unit.unit_id, action.action
180                                )));
181                            }
182                        }
183                    }
184                }
185            }
186
187            for (set_name, behavior_set) in &session.behavior_sets {
188                for behavior_name in &behavior_set.behaviors {
189                    let behavior = self.behaviors.get(behavior_name).ok_or_else(|| {
190                        ModbusError::Config(format!(
191                            "session '{}' behavior set '{}' references unknown behavior '{}'",
192                            name, set_name, behavior_name
193                        ))
194                    })?;
195                    for action_name in &behavior.actions {
196                        if !self.actions.contains_key(action_name) {
197                            return Err(ModbusError::Config(format!(
198                                "behavior '{}' references unknown action '{}'",
199                                behavior_name, action_name
200                            )));
201                        }
202                    }
203
204                    let matches = matching_behavior_targets(behavior, &compiled_profile);
205                    if matches.is_empty() {
206                        return Err(ModbusError::Config(format!(
207                            "behavior '{}' does not match any point in session '{}'",
208                            behavior_name, name
209                        )));
210                    }
211                }
212            }
213        }
214
215        Ok(())
216    }
217
218    /// Compiles a named session into a runtime launch spec and DX metadata.
219    pub fn compile_session(&self, name: &str) -> ModbusResult<CompiledModbusSession> {
220        let session = self
221            .sessions
222            .get(name)
223            .ok_or_else(|| ModbusError::Config(format!("unknown session '{}'", name)))?;
224        let profile = self.compile_profile(session)?;
225        let transport = self.transports.get(&session.transport).ok_or_else(|| {
226            ModbusError::Config(format!(
227                "session '{}' references unknown transport '{}'",
228                name, session.transport
229            ))
230        })?;
231
232        let launch = ProtocolLaunchSpec {
233            protocol: "modbus".into(),
234            name: Some(
235                session
236                    .service_name
237                    .clone()
238                    .unwrap_or_else(|| name.to_string()),
239            ),
240            config: serde_json::to_value(ModbusServiceLaunchConfig::from_session(
241                &self.defaults,
242                transport,
243                profile.clone(),
244            ))
245            .map_err(|error| {
246                ModbusError::Config(format!("failed to encode session launch config: {}", error))
247            })?,
248        };
249
250        let metadata = self.compile_session_metadata(session, &profile)?;
251
252        Ok(CompiledModbusSession {
253            session_name: name.to_string(),
254            launch,
255            transport_kind: transport.kind(),
256            profile,
257            trace: session.trace.resolved(&self.defaults),
258            reset: session.reset.clone(),
259            control: session.control.clone(),
260            fault_presets: session.fault_presets.clone(),
261            active_fault_preset: session.active_fault_preset.clone(),
262            response_profiles: self.response_profiles.clone(),
263            active_response_profile: session.active_response_profile.clone(),
264            actions: self.actions.clone(),
265            behaviors: self.behaviors.clone(),
266            behavior_sets: session.behavior_sets.clone(),
267            active_behavior_set: session.active_behavior_set.clone(),
268            point_catalog: metadata.point_catalog,
269            datastore_policies: metadata.datastore_policies,
270            action_binding_summaries: metadata.action_bindings,
271            behavior_binding_summaries: metadata.behavior_bindings,
272            compiled_behavior_bindings: metadata.compiled_behavior_bindings,
273            readiness_timeout_ms: session
274                .readiness_timeout_ms
275                .or(self.defaults.readiness_timeout_ms),
276        })
277    }
278
279    /// Returns a human-oriented summary for inspection surfaces.
280    pub fn inspect_summary(&self) -> ModbusConfigSummary {
281        ModbusConfigSummary {
282            transports: self.transports.keys().cloned().collect(),
283            datastores: self.datastores.keys().cloned().collect(),
284            devices: self.devices.keys().cloned().collect(),
285            sessions: self
286                .sessions
287                .iter()
288                .map(|(name, session)| SessionSummary {
289                    name: name.clone(),
290                    transport: session.transport.clone(),
291                    devices: session.devices.clone(),
292                    preset: session.preset.clone(),
293                    active_fault_preset: session.active_fault_preset.clone(),
294                    active_response_profile: session.active_response_profile.clone(),
295                    active_behavior_set: session.active_behavior_set.clone(),
296                })
297                .collect(),
298            presets: self.presets.keys().cloned().collect(),
299            actions: self.actions.keys().cloned().collect(),
300            behaviors: self.behaviors.keys().cloned().collect(),
301            response_profiles: self.response_profiles.keys().cloned().collect(),
302        }
303    }
304
305    fn compile_profile(&self, session: &SessionDefinition) -> ModbusResult<SimulatorProfile> {
306        let mut profile = if let Some(preset_name) = &session.preset {
307            self.presets
308                .get(preset_name)
309                .ok_or_else(|| ModbusError::Config(format!("unknown preset '{}'", preset_name)))?
310                .build(&self.datastores)?
311        } else {
312            SimulatorProfile::new()
313        };
314
315        for device_name in &session.devices {
316            let bundle = self.devices.get(device_name).ok_or_else(|| {
317                ModbusError::Config(format!("unknown device bundle '{}'", device_name))
318            })?;
319            let compiled = bundle.compile(&self.datastores)?;
320            profile.broadcast_enabled |= compiled.broadcast_enabled;
321            profile.units.extend(compiled.units);
322        }
323
324        Ok(profile)
325    }
326
327    fn compile_session_metadata(
328        &self,
329        session: &SessionDefinition,
330        profile: &SimulatorProfile,
331    ) -> ModbusResult<CompiledSessionMetadata> {
332        let mut point_catalog = BTreeMap::new();
333        for unit in &profile.units {
334            let device_id = format!("modbus-{}", unit.unit_id);
335            for point in &unit.points {
336                point_catalog.insert(
337                    point_catalog_key(&device_id, &point.id),
338                    CompiledPointMetadata {
339                        device_id: device_id.clone(),
340                        point_id: point.id.clone(),
341                        source_datastore: None,
342                        read_only: matches!(
343                            point.register_type,
344                            ModbusRegisterType::InputRegister | ModbusRegisterType::DiscreteInput
345                        ),
346                        invalid: false,
347                        action_bindings: Vec::new(),
348                        behavior_bindings: Vec::new(),
349                    },
350                );
351            }
352        }
353
354        let mut datastore_policies = BTreeMap::new();
355        let mut action_bindings = Vec::new();
356        let mut behavior_bindings = Vec::new();
357        let mut compiled_behavior_bindings = Vec::new();
358
359        if let Some(preset_name) = &session.preset {
360            if let Some(datastore) = self
361                .presets
362                .get(preset_name)
363                .and_then(|preset| preset.datastore.as_ref())
364            {
365                let datastore_name = datastore.reference_name();
366                let resolved = datastore.resolve(&self.datastores)?;
367                datastore_policies.insert(
368                    datastore_name
369                        .clone()
370                        .unwrap_or_else(|| format!("preset:{}", preset_name)),
371                    resolved.policy_summary(datastore_name.as_deref()),
372                );
373
374                for unit in &profile.units {
375                    let device_id = format!("modbus-{}", unit.unit_id);
376                    for point in &unit.points {
377                        let key = point_catalog_key(&device_id, &point.id);
378                        if let Some(entry) = point_catalog.get_mut(&key) {
379                            entry.source_datastore = datastore_name.clone();
380                            entry.read_only = entry.read_only
381                                || resolved.is_read_only(point.register_type, point.address);
382                            entry.invalid = resolved.is_invalid(point.register_type, point.address);
383                        }
384                    }
385                }
386            }
387        }
388
389        for device_name in &session.devices {
390            let bundle = self.devices.get(device_name).ok_or_else(|| {
391                ModbusError::Config(format!("unknown device bundle '{}'", device_name))
392            })?;
393            for unit in &bundle.units {
394                let device_id = format!("modbus-{}", unit.unit_id);
395                let datastore_name = unit
396                    .datastore
397                    .as_ref()
398                    .and_then(DatastoreSelector::reference_name);
399                let datastore = match &unit.datastore {
400                    Some(selector) => selector.resolve(&self.datastores)?,
401                    None => DatastoreDefinition::default(),
402                };
403                let summary_name = datastore_name
404                    .clone()
405                    .unwrap_or_else(|| format!("{}/unit-{}", device_name, unit.unit_id));
406                datastore_policies.insert(
407                    summary_name,
408                    datastore.policy_summary(datastore_name.as_deref()),
409                );
410
411                let binding_map = unit.binding_summary_map();
412                let binding_defs = unit.binding_definition_map();
413                for point in &unit.points {
414                    let key = point_catalog_key(&device_id, &point.id);
415                    let actions = binding_map.get(&point.id).cloned().unwrap_or_default();
416                    if let Some(entry) = point_catalog.get_mut(&key) {
417                        entry.source_datastore = datastore_name.clone();
418                        entry.read_only = entry.read_only
419                            || datastore.is_read_only(point.register_type, point.address);
420                        entry.invalid = datastore.is_invalid(point.register_type, point.address);
421                        entry.action_bindings = actions.clone();
422                        entry.behavior_bindings = actions.clone();
423                    }
424                    if !actions.is_empty() {
425                        action_bindings.push(ActionBindingSummary {
426                            device_id: device_id.clone(),
427                            point_id: point.id.clone(),
428                            bindings: actions,
429                        });
430                    }
431                    if let Some(definitions) = binding_defs.get(&point.id) {
432                        for definition in definitions {
433                            compiled_behavior_bindings.push(CompiledBehaviorBinding {
434                                name: definition.action.clone(),
435                                behavior_set: "__compat".to_string(),
436                                device_id: device_id.clone(),
437                                point_id: point.id.clone(),
438                                trigger: definition.trigger,
439                                condition: None,
440                                interval_ms: None,
441                                actions: vec![definition.action.clone()],
442                            });
443                        }
444                    }
445                }
446            }
447        }
448
449        for (set_name, behavior_set) in &session.behavior_sets {
450            for behavior_name in &behavior_set.behaviors {
451                let behavior = self.behaviors.get(behavior_name).ok_or_else(|| {
452                    ModbusError::Config(format!("unknown behavior '{}'", behavior_name))
453                })?;
454                for target in matching_behavior_targets(behavior, profile) {
455                    let key = point_catalog_key(&target.device_id, &target.point_id);
456                    if let Some(entry) = point_catalog.get_mut(&key) {
457                        entry
458                            .behavior_bindings
459                            .push(format!("{}@{}", behavior_name, set_name));
460                    }
461                    behavior_bindings.push(BehaviorBindingSummary {
462                        device_id: target.device_id.clone(),
463                        point_id: target.point_id.clone(),
464                        behavior: behavior_name.clone(),
465                        behavior_set: set_name.clone(),
466                        trigger: behavior.trigger,
467                    });
468                    compiled_behavior_bindings.push(CompiledBehaviorBinding {
469                        name: behavior_name.clone(),
470                        behavior_set: set_name.clone(),
471                        device_id: target.device_id,
472                        point_id: target.point_id,
473                        trigger: behavior.trigger,
474                        condition: behavior.condition.clone(),
475                        interval_ms: behavior.interval_ms,
476                        actions: behavior.actions.clone(),
477                    });
478                }
479            }
480        }
481
482        Ok(CompiledSessionMetadata {
483            point_catalog,
484            datastore_policies: datastore_policies.into_values().collect(),
485            action_bindings,
486            behavior_bindings,
487            compiled_behavior_bindings,
488        })
489    }
490}
491
492/// Defaults applied during session compilation.
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct SimulatorDefaults {
495    #[serde(default = "default_trace_capacity")]
496    pub trace_capacity: usize,
497    #[serde(default)]
498    pub tcp_performance_preset: TcpPerformancePreset,
499    #[serde(default)]
500    pub rtu_performance_preset: RtuPerformancePreset,
501    #[serde(default)]
502    pub readiness_timeout_ms: Option<u64>,
503}
504
505impl Default for SimulatorDefaults {
506    fn default() -> Self {
507        Self {
508            trace_capacity: default_trace_capacity(),
509            tcp_performance_preset: TcpPerformancePreset::Default,
510            rtu_performance_preset: RtuPerformancePreset::Default,
511            readiness_timeout_ms: None,
512        }
513    }
514}
515
516fn default_trace_capacity() -> usize {
517    256
518}
519
520/// Named transport definition, inspired by PyModbus `server_list`.
521#[derive(Debug, Clone, Serialize, Deserialize)]
522#[serde(tag = "kind", rename_all = "snake_case")]
523pub enum TransportDefinition {
524    Tcp {
525        #[serde(default = "default_bind")]
526        bind: String,
527        #[serde(default = "default_tcp_port")]
528        port: u16,
529        #[serde(default)]
530        performance_preset: Option<TcpPerformancePreset>,
531    },
532    Rtu {
533        #[serde(default)]
534        config: RtuServerConfig,
535    },
536}
537
538impl TransportDefinition {
539    fn kind(&self) -> CompiledTransportKind {
540        match self {
541            Self::Tcp { .. } => CompiledTransportKind::Tcp,
542            Self::Rtu { .. } => CompiledTransportKind::Rtu,
543        }
544    }
545}
546
547fn default_bind() -> String {
548    "0.0.0.0".to_string()
549}
550
551fn default_tcp_port() -> u16 {
552    502
553}
554
555/// Address-range policy used by named datastore definitions.
556#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct DatastoreAddressRange {
558    pub register_type: ModbusRegisterType,
559    pub start: u16,
560    pub quantity: u16,
561}
562
563impl DatastoreAddressRange {
564    fn matches(&self, register_type: ModbusRegisterType, address: u16) -> bool {
565        self.register_type == register_type
566            && address >= self.start
567            && address < self.start.saturating_add(self.quantity)
568    }
569}
570
571/// Named reusable typed block metadata for simulator datastores.
572#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct DatastoreTypedBlock {
574    pub register_type: ModbusRegisterType,
575    pub start: u16,
576    pub quantity: u16,
577    pub data_type: DataType,
578}
579
580/// Optional repeat metadata for patterned simulators.
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct DatastoreRepeatPolicy {
583    pub every: u16,
584    pub count: u16,
585}
586
587/// Initialization hints compiled alongside a datastore definition.
588#[derive(Debug, Clone, Serialize, Deserialize)]
589#[serde(rename_all = "snake_case")]
590pub enum DatastoreInitialization {
591    Zero,
592    One,
593    Max,
594    Preserve,
595}
596
597/// Reusable named datastore definitions for simulator device bundles.
598#[derive(Debug, Clone, Serialize, Deserialize)]
599pub struct DatastoreDefinition {
600    #[serde(flatten)]
601    pub kind: DatastoreKind,
602    #[serde(default)]
603    pub invalid_ranges: Vec<DatastoreAddressRange>,
604    #[serde(default)]
605    pub readonly_ranges: Vec<DatastoreAddressRange>,
606    #[serde(default)]
607    pub typed_blocks: Vec<DatastoreTypedBlock>,
608    #[serde(default)]
609    pub default_value: Option<JsonValue>,
610    #[serde(default)]
611    pub repeat: Option<DatastoreRepeatPolicy>,
612    #[serde(default)]
613    pub initialization: Option<DatastoreInitialization>,
614}
615
616impl Default for DatastoreDefinition {
617    fn default() -> Self {
618        Self {
619            kind: DatastoreKind::default(),
620            invalid_ranges: Vec::new(),
621            readonly_ranges: Vec::new(),
622            typed_blocks: Vec::new(),
623            default_value: None,
624            repeat: None,
625            initialization: None,
626        }
627    }
628}
629
630impl From<DatastoreKind> for DatastoreDefinition {
631    fn from(value: DatastoreKind) -> Self {
632        Self {
633            kind: value,
634            ..Default::default()
635        }
636    }
637}
638
639impl DatastoreDefinition {
640    fn is_invalid(&self, register_type: ModbusRegisterType, address: u16) -> bool {
641        self.invalid_ranges
642            .iter()
643            .any(|range| range.matches(register_type, address))
644    }
645
646    fn is_read_only(&self, register_type: ModbusRegisterType, address: u16) -> bool {
647        matches!(
648            register_type,
649            ModbusRegisterType::InputRegister | ModbusRegisterType::DiscreteInput
650        ) || self
651            .readonly_ranges
652            .iter()
653            .any(|range| range.matches(register_type, address))
654    }
655
656    fn policy_summary(&self, name: Option<&str>) -> DatastorePolicySummary {
657        DatastorePolicySummary {
658            name: name.unwrap_or("inline").to_string(),
659            kind: match self.kind {
660                DatastoreKind::Dense { .. } => "dense".to_string(),
661                DatastoreKind::Sparse { .. } => "sparse".to_string(),
662            },
663            invalid_ranges: self.invalid_ranges.len(),
664            readonly_ranges: self.readonly_ranges.len(),
665            typed_blocks: self.typed_blocks.len(),
666            has_default_value: self.default_value.is_some(),
667            repeat: self.repeat.clone(),
668            initialization: self
669                .initialization
670                .as_ref()
671                .map(|value| format!("{:?}", value).to_lowercase()),
672        }
673    }
674}
675
676/// Named or inline datastore selection used by devices and presets.
677#[derive(Debug, Clone, Serialize, Deserialize)]
678#[serde(untagged)]
679pub enum DatastoreSelector {
680    Named(String),
681    Inline(DatastoreDefinition),
682}
683
684impl DatastoreSelector {
685    fn resolve(
686        &self,
687        datastores: &BTreeMap<String, DatastoreDefinition>,
688    ) -> ModbusResult<DatastoreDefinition> {
689        match self {
690            Self::Named(name) => datastores
691                .get(name)
692                .cloned()
693                .ok_or_else(|| ModbusError::Config(format!("unknown datastore '{}'", name))),
694            Self::Inline(datastore) => Ok(datastore.clone()),
695        }
696    }
697
698    fn reference_name(&self) -> Option<String> {
699        match self {
700            Self::Named(name) => Some(name.clone()),
701            Self::Inline(_) => None,
702        }
703    }
704}
705
706/// Target selector for deterministic behaviors.
707#[derive(Debug, Clone, Serialize, Deserialize)]
708pub struct BehaviorTarget {
709    #[serde(default)]
710    pub device_id: Option<String>,
711    #[serde(default)]
712    pub unit_id: Option<u8>,
713    pub point_id: String,
714}
715
716/// Supported deterministic comparison operators.
717#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
718#[serde(rename_all = "snake_case")]
719pub enum BehaviorConditionOperator {
720    Eq,
721    Ne,
722    Gt,
723    Gte,
724    Lt,
725    Lte,
726    Changed,
727}
728
729/// Optional deterministic guard used by a behavior definition.
730#[derive(Debug, Clone, Serialize, Deserialize)]
731pub struct BehaviorCondition {
732    pub operator: BehaviorConditionOperator,
733    #[serde(default)]
734    pub value: Option<JsonValue>,
735}
736
737/// Deterministic behavior triggers.
738#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
739#[serde(rename_all = "snake_case")]
740pub enum BehaviorTrigger {
741    OnRead,
742    #[default]
743    OnWrite,
744    OnInterval,
745    #[serde(alias = "on_boot")]
746    OnStartup,
747    OnReset,
748}
749
750impl BehaviorTrigger {
751    fn as_str(self) -> &'static str {
752        match self {
753            Self::OnRead => "on_read",
754            Self::OnWrite => "on_write",
755            Self::OnInterval => "on_interval",
756            Self::OnStartup => "on_startup",
757            Self::OnReset => "on_reset",
758        }
759    }
760}
761
762/// Backward-compatible alias for older action binding configs.
763pub type ActionTrigger = BehaviorTrigger;
764
765/// Deterministic action catalog definition inspired by PyModbus simulator hooks.
766#[derive(Debug, Clone, Serialize, Deserialize)]
767#[serde(tag = "kind", rename_all = "snake_case")]
768pub enum ActionDefinition {
769    SetValue {
770        value: JsonValue,
771    },
772    CopyToPoint {
773        target_point_id: String,
774    },
775    Clamp {
776        min: f64,
777        max: f64,
778    },
779    Mirror {
780        target_point_id: String,
781    },
782    Scale {
783        factor: f64,
784        #[serde(default)]
785        offset: f64,
786    },
787    Offset {
788        value: f64,
789    },
790    Map {
791        mapping: BTreeMap<String, JsonValue>,
792        #[serde(default)]
793        default: Option<JsonValue>,
794    },
795    MaskBits {
796        #[serde(default)]
797        and_mask: Option<u64>,
798        #[serde(default)]
799        or_mask: Option<u64>,
800    },
801    MarkInvalid,
802    ClearInvalid,
803    Latch,
804    Pulse {
805        duration_ms: u64,
806    },
807    Rotate {
808        values: Vec<JsonValue>,
809    },
810}
811
812/// Deterministic behavior definition referencing a catalog of actions.
813#[derive(Debug, Clone, Serialize, Deserialize)]
814pub struct BehaviorDefinition {
815    pub target: BehaviorTarget,
816    #[serde(default)]
817    pub trigger: BehaviorTrigger,
818    #[serde(default)]
819    pub condition: Option<BehaviorCondition>,
820    #[serde(default)]
821    pub interval_ms: Option<u64>,
822    #[serde(default)]
823    pub actions: Vec<String>,
824}
825
826/// Named behavior set scoped to a session.
827#[derive(Debug, Clone, Default, Serialize, Deserialize)]
828pub struct BehaviorSetDefinition {
829    #[serde(default)]
830    pub behaviors: Vec<String>,
831}
832
833/// Action binding applied to a point definition.
834#[derive(Debug, Clone, Serialize, Deserialize)]
835pub struct ActionBindingDefinition {
836    pub action: String,
837    #[serde(default)]
838    pub trigger: BehaviorTrigger,
839}
840
841impl ActionBindingDefinition {
842    fn summary(&self) -> String {
843        format!("{}@{}", self.action, self.trigger.as_str())
844    }
845}
846
847/// Point-level action bindings compiled into session metadata.
848#[derive(Debug, Clone, Default, Serialize, Deserialize)]
849pub struct PointActionBinding {
850    pub point_id: String,
851    #[serde(default)]
852    pub bindings: Vec<ActionBindingDefinition>,
853}
854
855/// Deterministic wire-level response profile.
856#[derive(Debug, Clone, Default, Serialize, Deserialize)]
857pub struct ResponseProfileDefinition {
858    #[serde(default)]
859    pub delay_ms: Option<u64>,
860    #[serde(default)]
861    pub exception_code: Option<u8>,
862    #[serde(default)]
863    pub split_response: Option<SplitResponseDefinition>,
864    #[serde(default)]
865    pub partial_response: Option<PartialResponseDefinition>,
866    #[serde(default)]
867    pub silent_drop: bool,
868    #[serde(default)]
869    pub malformed_response: Option<MalformedResponseDefinition>,
870}
871
872impl ResponseProfileDefinition {
873    fn to_fault_injection_config(
874        &self,
875        transport_kind: CompiledTransportKind,
876    ) -> Option<FaultInjectionConfig> {
877        let mut config = FaultInjectionConfig::new();
878
879        if let Some(delay_ms) = self.delay_ms {
880            config = config.with_fault(FaultConfig::delayed_response(
881                std::time::Duration::from_millis(delay_ms),
882                std::time::Duration::ZERO,
883                FaultTarget::new(),
884            ));
885        }
886        if let Some(exception_code) = self.exception_code {
887            config = config.with_fault(FaultConfig::exception_injection(
888                exception_code,
889                FaultTarget::new(),
890            ));
891        }
892        if self.silent_drop {
893            config = config.with_fault(FaultConfig::no_response(FaultTarget::new()));
894        }
895        if let Some(partial) = &self.partial_response {
896            config = config.with_fault(partial.to_fault_config(transport_kind));
897        }
898        if let Some(split) = &self.split_response {
899            config = config.with_fault(split.to_fault_config(transport_kind));
900        }
901        if let Some(malformed) = &self.malformed_response {
902            malformed.append_faults(transport_kind, &mut config);
903        }
904
905        if config.faults.is_empty() {
906            None
907        } else {
908            Some(config)
909        }
910    }
911}
912
913#[derive(Debug, Clone, Serialize, Deserialize)]
914pub struct SplitResponseDefinition {
915    pub first_chunk_bytes: usize,
916}
917
918impl SplitResponseDefinition {
919    fn to_fault_config(&self, transport_kind: CompiledTransportKind) -> FaultConfig {
920        match transport_kind {
921            CompiledTransportKind::Tcp => FaultConfig {
922                fault_type: crate::fault_injection::FaultType::TruncatedResponse,
923                target: FaultTarget::new(),
924                config: FaultTypeConfig {
925                    truncation_mode: Some(TruncationMode::FixedBytes),
926                    truncation_bytes: Some(self.first_chunk_bytes),
927                    ..Default::default()
928                },
929            },
930            CompiledTransportKind::Rtu => FaultConfig {
931                fault_type: crate::fault_injection::FaultType::PartialFrame,
932                target: FaultTarget::new(),
933                config: FaultTypeConfig {
934                    partial_mode: Some(PartialFrameMode::FixedCount),
935                    partial_bytes: Some(self.first_chunk_bytes),
936                    ..Default::default()
937                },
938            },
939        }
940    }
941}
942
943#[derive(Debug, Clone, Serialize, Deserialize)]
944pub struct PartialResponseDefinition {
945    #[serde(default)]
946    pub bytes: Option<usize>,
947    #[serde(default)]
948    pub percentage: Option<f64>,
949}
950
951impl PartialResponseDefinition {
952    fn to_fault_config(&self, transport_kind: CompiledTransportKind) -> FaultConfig {
953        match transport_kind {
954            CompiledTransportKind::Tcp => FaultConfig {
955                fault_type: crate::fault_injection::FaultType::TruncatedResponse,
956                target: FaultTarget::new(),
957                config: FaultTypeConfig {
958                    truncation_mode: Some(if self.bytes.is_some() {
959                        TruncationMode::FixedBytes
960                    } else {
961                        TruncationMode::Percentage
962                    }),
963                    truncation_bytes: self.bytes,
964                    truncation_percentage: self.percentage,
965                    ..Default::default()
966                },
967            },
968            CompiledTransportKind::Rtu => FaultConfig {
969                fault_type: crate::fault_injection::FaultType::PartialFrame,
970                target: FaultTarget::new(),
971                config: FaultTypeConfig {
972                    partial_mode: Some(if self.bytes.is_some() {
973                        PartialFrameMode::FixedCount
974                    } else {
975                        PartialFrameMode::Percentage
976                    }),
977                    partial_bytes: self.bytes,
978                    partial_percentage: self.percentage,
979                    ..Default::default()
980                },
981            },
982        }
983    }
984}
985
986#[derive(Debug, Clone, Default, Serialize, Deserialize)]
987pub struct MalformedResponseDefinition {
988    #[serde(default)]
989    pub truncate_bytes: Option<usize>,
990    #[serde(default)]
991    pub append_bytes: Option<Vec<u8>>,
992    #[serde(default)]
993    pub append_random_count: Option<usize>,
994    #[serde(default)]
995    pub header_only: bool,
996}
997
998impl MalformedResponseDefinition {
999    fn append_faults(
1000        &self,
1001        _transport_kind: CompiledTransportKind,
1002        config: &mut FaultInjectionConfig,
1003    ) {
1004        if self.header_only {
1005            config.faults.push(FaultConfig {
1006                fault_type: crate::fault_injection::FaultType::TruncatedResponse,
1007                target: FaultTarget::new(),
1008                config: FaultTypeConfig {
1009                    truncation_mode: Some(TruncationMode::HeaderOnly),
1010                    ..Default::default()
1011                },
1012            });
1013        }
1014
1015        if let Some(bytes) = self.truncate_bytes {
1016            config.faults.push(FaultConfig {
1017                fault_type: crate::fault_injection::FaultType::TruncatedResponse,
1018                target: FaultTarget::new(),
1019                config: FaultTypeConfig {
1020                    truncation_mode: Some(TruncationMode::RemoveLastN),
1021                    truncation_bytes: Some(bytes),
1022                    ..Default::default()
1023                },
1024            });
1025        }
1026
1027        if let Some(bytes) = &self.append_bytes {
1028            config.faults.push(FaultConfig {
1029                fault_type: crate::fault_injection::FaultType::ExtraData,
1030                target: FaultTarget::new(),
1031                config: FaultTypeConfig {
1032                    extra_data_mode: Some(ExtraDataMode::AppendBytes),
1033                    extra_bytes: Some(bytes.clone()),
1034                    extra_count: Some(bytes.len()),
1035                    ..Default::default()
1036                },
1037            });
1038        }
1039
1040        if let Some(count) = self.append_random_count {
1041            config.faults.push(FaultConfig::extra_data(
1042                ExtraDataMode::AppendRandom,
1043                count,
1044                FaultTarget::new(),
1045            ));
1046        }
1047    }
1048}
1049
1050/// Richer simulator device bundle definition inspired by PyModbus contexts.
1051#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1052pub struct DeviceBundleDefinition {
1053    #[serde(default = "default_true")]
1054    pub broadcast_enabled: bool,
1055    #[serde(default)]
1056    pub units: Vec<UnitDefinition>,
1057}
1058
1059impl DeviceBundleDefinition {
1060    fn compile(
1061        &self,
1062        datastores: &BTreeMap<String, DatastoreDefinition>,
1063    ) -> ModbusResult<SimulatorProfile> {
1064        let mut profile = SimulatorProfile::new();
1065        profile.broadcast_enabled = self.broadcast_enabled;
1066        for unit in &self.units {
1067            profile.units.push(unit.compile(datastores)?);
1068        }
1069        Ok(profile)
1070    }
1071}
1072
1073/// File-backed unit definition with reusable datastore references.
1074#[derive(Debug, Clone, Serialize, Deserialize)]
1075pub struct UnitDefinition {
1076    pub unit_id: u8,
1077    pub name: String,
1078    #[serde(default)]
1079    pub datastore: Option<DatastoreSelector>,
1080    #[serde(default)]
1081    pub points: Vec<crate::profile::PointProfile>,
1082    #[serde(default)]
1083    pub response_delay_ms: u64,
1084    #[serde(default)]
1085    pub word_order: crate::types::WordOrder,
1086    #[serde(default = "default_true")]
1087    pub broadcast_enabled: bool,
1088    #[serde(default)]
1089    pub action_bindings: Vec<PointActionBinding>,
1090    #[serde(default, skip_serializing_if = "mabi_core::tags::Tags::is_empty")]
1091    pub tags: mabi_core::tags::Tags,
1092}
1093
1094impl UnitDefinition {
1095    fn compile(
1096        &self,
1097        datastores: &BTreeMap<String, DatastoreDefinition>,
1098    ) -> ModbusResult<crate::profile::UnitProfile> {
1099        let datastore = match &self.datastore {
1100            Some(datastore) => datastore.resolve(datastores)?.kind,
1101            None => DatastoreKind::default(),
1102        };
1103
1104        Ok(crate::profile::UnitProfile {
1105            unit_id: self.unit_id,
1106            name: self.name.clone(),
1107            datastore,
1108            points: self.points.clone(),
1109            response_delay_ms: self.response_delay_ms,
1110            word_order: self.word_order,
1111            broadcast_enabled: self.broadcast_enabled,
1112            tags: self.tags.clone(),
1113        })
1114    }
1115
1116    fn binding_summary_map(&self) -> BTreeMap<String, Vec<String>> {
1117        self.action_bindings
1118            .iter()
1119            .map(|binding| {
1120                (
1121                    binding.point_id.clone(),
1122                    binding
1123                        .bindings
1124                        .iter()
1125                        .map(ActionBindingDefinition::summary)
1126                        .collect(),
1127                )
1128            })
1129            .collect()
1130    }
1131
1132    fn binding_definition_map(&self) -> BTreeMap<String, Vec<ActionBindingDefinition>> {
1133        self.action_bindings
1134            .iter()
1135            .map(|binding| (binding.point_id.clone(), binding.bindings.clone()))
1136            .collect()
1137    }
1138}
1139
1140/// Generated quickstart preset replacing the old numeric CLI surface.
1141#[derive(Debug, Clone, Serialize, Deserialize)]
1142pub struct GeneratedPresetDefinition {
1143    pub devices: usize,
1144    pub points_per_device: usize,
1145    #[serde(default)]
1146    pub datastore: Option<DatastoreSelector>,
1147}
1148
1149impl GeneratedPresetDefinition {
1150    fn build(
1151        &self,
1152        datastores: &BTreeMap<String, DatastoreDefinition>,
1153    ) -> ModbusResult<SimulatorProfile> {
1154        let mut profile = GeneratedProfilePreset::new(self.devices, self.points_per_device).build();
1155        if let Some(datastore) = &self.datastore {
1156            let datastore = datastore.resolve(datastores)?.kind;
1157            for unit in &mut profile.units {
1158                unit.datastore = datastore.clone();
1159            }
1160        }
1161        Ok(profile)
1162    }
1163}
1164
1165/// Session-level DX configuration.
1166#[derive(Debug, Clone, Serialize, Deserialize)]
1167pub struct SessionDefinition {
1168    pub transport: String,
1169    #[serde(default)]
1170    pub service_name: Option<String>,
1171    #[serde(default)]
1172    pub devices: Vec<String>,
1173    #[serde(default)]
1174    pub preset: Option<String>,
1175    #[serde(default)]
1176    pub trace: SessionTraceConfig,
1177    #[serde(default)]
1178    pub reset: SessionResetPolicy,
1179    #[serde(default)]
1180    pub fault_presets: BTreeMap<String, FaultInjectionConfig>,
1181    #[serde(default)]
1182    pub active_fault_preset: Option<String>,
1183    #[serde(default)]
1184    pub active_response_profile: Option<String>,
1185    #[serde(default)]
1186    pub behavior_sets: BTreeMap<String, BehaviorSetDefinition>,
1187    #[serde(default)]
1188    pub active_behavior_set: Option<String>,
1189    #[serde(default)]
1190    pub readiness_timeout_ms: Option<u64>,
1191    #[serde(default)]
1192    pub control: SessionControlConfig,
1193}
1194
1195/// Trace buffer configuration for operator-facing control flows.
1196#[derive(Debug, Clone, Serialize, Deserialize)]
1197pub struct SessionTraceConfig {
1198    #[serde(default = "default_true")]
1199    pub enabled: bool,
1200    #[serde(default)]
1201    pub capacity: Option<usize>,
1202}
1203
1204impl Default for SessionTraceConfig {
1205    fn default() -> Self {
1206        Self {
1207            enabled: true,
1208            capacity: None,
1209        }
1210    }
1211}
1212
1213impl SessionTraceConfig {
1214    fn resolved(&self, defaults: &SimulatorDefaults) -> Self {
1215        Self {
1216            enabled: self.enabled,
1217            capacity: Some(self.capacity.unwrap_or(defaults.trace_capacity)),
1218        }
1219    }
1220
1221    pub fn buffer_capacity(&self) -> usize {
1222        self.capacity.unwrap_or(default_trace_capacity())
1223    }
1224}
1225
1226fn default_true() -> bool {
1227    true
1228}
1229
1230/// Session reset defaults for the control-plane.
1231#[derive(Debug, Clone, Serialize, Deserialize)]
1232pub struct SessionResetPolicy {
1233    #[serde(default = "default_true")]
1234    pub clear_fault_preset: bool,
1235    #[serde(default = "default_true")]
1236    pub clear_response_profile: bool,
1237    #[serde(default = "default_true")]
1238    pub clear_behavior_set: bool,
1239    #[serde(default = "default_true")]
1240    pub clear_trace_buffer: bool,
1241}
1242
1243impl Default for SessionResetPolicy {
1244    fn default() -> Self {
1245        Self {
1246            clear_fault_preset: true,
1247            clear_response_profile: true,
1248            clear_behavior_set: true,
1249            clear_trace_buffer: true,
1250        }
1251    }
1252}
1253
1254/// Default CLI/control hints compiled alongside a session.
1255#[derive(Debug, Clone, Serialize, Deserialize)]
1256pub struct SessionControlConfig {
1257    #[serde(default = "default_trace_tail")]
1258    pub default_trace_tail: usize,
1259    #[serde(default = "default_point_limit")]
1260    pub default_point_limit: usize,
1261}
1262
1263impl Default for SessionControlConfig {
1264    fn default() -> Self {
1265        Self {
1266            default_trace_tail: default_trace_tail(),
1267            default_point_limit: default_point_limit(),
1268        }
1269    }
1270}
1271
1272fn default_trace_tail() -> usize {
1273    20
1274}
1275
1276fn default_point_limit() -> usize {
1277    100
1278}
1279
1280/// Runtime launch config consumed by the Modbus protocol driver.
1281#[derive(Debug, Clone, Serialize, Deserialize)]
1282pub struct ModbusServiceLaunchConfig {
1283    pub transport: ModbusTransportLaunch,
1284    #[serde(default)]
1285    pub profile: Option<SimulatorProfile>,
1286    #[serde(default)]
1287    pub devices: Option<usize>,
1288    #[serde(default)]
1289    pub points_per_device: Option<usize>,
1290}
1291
1292impl ModbusServiceLaunchConfig {
1293    fn from_session(
1294        defaults: &SimulatorDefaults,
1295        transport: &TransportDefinition,
1296        profile: SimulatorProfile,
1297    ) -> Self {
1298        let transport = match transport {
1299            TransportDefinition::Tcp {
1300                bind,
1301                port,
1302                performance_preset,
1303            } => {
1304                let bind_address: SocketAddr = format!("{}:{}", bind, port)
1305                    .parse()
1306                    .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], *port)));
1307                ModbusTransportLaunch::Tcp {
1308                    bind_addr: bind_address,
1309                    performance_preset: performance_preset
1310                        .unwrap_or(defaults.tcp_performance_preset),
1311                }
1312            }
1313            TransportDefinition::Rtu { config } => {
1314                let mut config = config.clone();
1315                if matches!(config.performance_preset, RtuPerformancePreset::Default) {
1316                    config.performance_preset = defaults.rtu_performance_preset;
1317                }
1318                ModbusTransportLaunch::Rtu { config }
1319            }
1320        };
1321
1322        Self {
1323            transport,
1324            profile: Some(profile),
1325            devices: None,
1326            points_per_device: None,
1327        }
1328    }
1329}
1330
1331/// Per-service transport launch configuration.
1332#[derive(Debug, Clone, Serialize, Deserialize)]
1333#[serde(tag = "kind", rename_all = "snake_case")]
1334pub enum ModbusTransportLaunch {
1335    Tcp {
1336        bind_addr: SocketAddr,
1337        #[serde(default)]
1338        performance_preset: TcpPerformancePreset,
1339    },
1340    Rtu {
1341        #[serde(default)]
1342        config: RtuServerConfig,
1343    },
1344}
1345
1346/// Compiled runtime-ready session.
1347#[derive(Debug, Clone)]
1348pub struct CompiledModbusSession {
1349    pub session_name: String,
1350    pub launch: ProtocolLaunchSpec,
1351    pub transport_kind: CompiledTransportKind,
1352    pub profile: SimulatorProfile,
1353    pub trace: SessionTraceConfig,
1354    pub reset: SessionResetPolicy,
1355    pub control: SessionControlConfig,
1356    pub fault_presets: BTreeMap<String, FaultInjectionConfig>,
1357    pub active_fault_preset: Option<String>,
1358    pub response_profiles: BTreeMap<String, ResponseProfileDefinition>,
1359    pub active_response_profile: Option<String>,
1360    pub actions: BTreeMap<String, ActionDefinition>,
1361    pub behaviors: BTreeMap<String, BehaviorDefinition>,
1362    pub behavior_sets: BTreeMap<String, BehaviorSetDefinition>,
1363    pub active_behavior_set: Option<String>,
1364    pub point_catalog: BTreeMap<String, CompiledPointMetadata>,
1365    pub datastore_policies: Vec<DatastorePolicySummary>,
1366    pub action_binding_summaries: Vec<ActionBindingSummary>,
1367    pub behavior_binding_summaries: Vec<BehaviorBindingSummary>,
1368    pub compiled_behavior_bindings: Vec<CompiledBehaviorBinding>,
1369    pub readiness_timeout_ms: Option<u64>,
1370}
1371
1372impl CompiledModbusSession {
1373    /// Returns the runtime extensions implied by the compiled session.
1374    pub fn runtime_extensions(&self) -> RuntimeExtensions {
1375        let mut extensions = RuntimeExtensions::default();
1376        if let Some(config) = self.active_runtime_fault_config() {
1377            extensions.insert_protocol_config(
1378                "modbus",
1379                json!({
1380                    "fault_injection": config,
1381                }),
1382            );
1383        }
1384        if let Some(layer) = BehaviorLayer::from_compiled(self) {
1385            extensions.add_device_layer(Arc::new(layer));
1386        }
1387        extensions
1388    }
1389
1390    /// Returns a cloned session with a different active fault preset.
1391    pub fn with_active_fault_preset(&self, preset: Option<&str>) -> ModbusResult<Self> {
1392        if let Some(name) = preset {
1393            if !self.fault_presets.contains_key(name) {
1394                return Err(ModbusError::Config(format!(
1395                    "unknown fault preset '{}'",
1396                    name
1397                )));
1398            }
1399        }
1400
1401        let mut cloned = self.clone();
1402        cloned.active_fault_preset = preset.map(|value| value.to_string());
1403        Ok(cloned)
1404    }
1405
1406    /// Returns a cloned session with a different active response profile.
1407    pub fn with_active_response_profile(&self, profile: Option<&str>) -> ModbusResult<Self> {
1408        if let Some(name) = profile {
1409            if !self.response_profiles.contains_key(name) {
1410                return Err(ModbusError::Config(format!(
1411                    "unknown response profile '{}'",
1412                    name
1413                )));
1414            }
1415        }
1416
1417        let mut cloned = self.clone();
1418        cloned.active_response_profile = profile.map(|value| value.to_string());
1419        Ok(cloned)
1420    }
1421
1422    /// Returns a cloned session with a different active behavior set.
1423    pub fn with_active_behavior_set(&self, behavior_set: Option<&str>) -> ModbusResult<Self> {
1424        if let Some(name) = behavior_set {
1425            if !self.behavior_sets.contains_key(name) {
1426                return Err(ModbusError::Config(format!(
1427                    "unknown behavior set '{}'",
1428                    name
1429                )));
1430            }
1431        }
1432
1433        let mut cloned = self.clone();
1434        cloned.active_behavior_set = behavior_set.map(|value| value.to_string());
1435        Ok(cloned)
1436    }
1437
1438    /// Returns the currently active fault injection config, if one exists.
1439    pub fn active_fault_config(&self) -> Option<FaultInjectionConfig> {
1440        self.active_fault_preset
1441            .as_ref()
1442            .and_then(|name| self.fault_presets.get(name).cloned())
1443    }
1444
1445    /// Returns the currently active response profile, if one exists.
1446    pub fn active_response_profile_definition(&self) -> Option<ResponseProfileDefinition> {
1447        self.active_response_profile
1448            .as_ref()
1449            .and_then(|name| self.response_profiles.get(name).cloned())
1450    }
1451
1452    /// Returns the merged runtime fault config implied by fault presets and response profiles.
1453    pub fn active_runtime_fault_config(&self) -> Option<FaultInjectionConfig> {
1454        let mut merged = FaultInjectionConfig::new();
1455        let mut enabled = false;
1456        let mut has_source = false;
1457
1458        if let Some(config) = self.active_fault_config() {
1459            has_source = true;
1460            enabled |= config.enabled;
1461            merged.faults.extend(config.faults);
1462        }
1463        if let Some(config) = self
1464            .active_response_profile_definition()
1465            .and_then(|profile| profile.to_fault_injection_config(self.transport_kind))
1466        {
1467            has_source = true;
1468            enabled |= config.enabled;
1469            merged.faults.extend(config.faults);
1470        }
1471
1472        if !has_source {
1473            None
1474        } else {
1475            merged.enabled = enabled;
1476            Some(merged)
1477        }
1478    }
1479
1480    pub fn point_metadata(
1481        &self,
1482        device_id: &str,
1483        point_id: &str,
1484    ) -> Option<&CompiledPointMetadata> {
1485        self.point_catalog
1486            .get(&point_catalog_key(device_id, point_id))
1487    }
1488}
1489
1490/// Inspection summary used by the CLI.
1491#[derive(Debug, Clone, Serialize)]
1492pub struct ModbusConfigSummary {
1493    pub transports: Vec<String>,
1494    pub datastores: Vec<String>,
1495    pub devices: Vec<String>,
1496    pub sessions: Vec<SessionSummary>,
1497    pub presets: Vec<String>,
1498    pub actions: Vec<String>,
1499    pub behaviors: Vec<String>,
1500    pub response_profiles: Vec<String>,
1501}
1502
1503/// Inspection summary for a single named session.
1504#[derive(Debug, Clone, Serialize)]
1505pub struct SessionSummary {
1506    pub name: String,
1507    pub transport: String,
1508    pub devices: Vec<String>,
1509    pub preset: Option<String>,
1510    pub active_fault_preset: Option<String>,
1511    pub active_response_profile: Option<String>,
1512    pub active_behavior_set: Option<String>,
1513}
1514
1515/// Session transport kind compiled alongside immutable runtime metadata.
1516#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1517pub enum CompiledTransportKind {
1518    Tcp,
1519    Rtu,
1520}
1521
1522/// Compiled point metadata surfaced through the control plane.
1523#[derive(Debug, Clone, Serialize)]
1524pub struct CompiledPointMetadata {
1525    pub device_id: String,
1526    pub point_id: String,
1527    pub source_datastore: Option<String>,
1528    pub read_only: bool,
1529    pub invalid: bool,
1530    pub action_bindings: Vec<String>,
1531    pub behavior_bindings: Vec<String>,
1532}
1533
1534/// Summary of a compiled datastore policy.
1535#[derive(Debug, Clone, Serialize)]
1536pub struct DatastorePolicySummary {
1537    pub name: String,
1538    pub kind: String,
1539    pub invalid_ranges: usize,
1540    pub readonly_ranges: usize,
1541    pub typed_blocks: usize,
1542    pub has_default_value: bool,
1543    pub repeat: Option<DatastoreRepeatPolicy>,
1544    pub initialization: Option<String>,
1545}
1546
1547/// Summary of action bindings compiled into a session.
1548#[derive(Debug, Clone, Serialize)]
1549pub struct ActionBindingSummary {
1550    pub device_id: String,
1551    pub point_id: String,
1552    pub bindings: Vec<String>,
1553}
1554
1555/// Summary of behavior bindings compiled into a session.
1556#[derive(Debug, Clone, Serialize)]
1557pub struct BehaviorBindingSummary {
1558    pub device_id: String,
1559    pub point_id: String,
1560    pub behavior: String,
1561    pub behavior_set: String,
1562    pub trigger: BehaviorTrigger,
1563}
1564
1565/// Runtime-ready compiled behavior binding.
1566#[derive(Debug, Clone)]
1567pub struct CompiledBehaviorBinding {
1568    pub name: String,
1569    pub behavior_set: String,
1570    pub device_id: String,
1571    pub point_id: String,
1572    pub trigger: BehaviorTrigger,
1573    pub condition: Option<BehaviorCondition>,
1574    pub interval_ms: Option<u64>,
1575    pub actions: Vec<String>,
1576}
1577
1578struct CompiledSessionMetadata {
1579    point_catalog: BTreeMap<String, CompiledPointMetadata>,
1580    datastore_policies: Vec<DatastorePolicySummary>,
1581    action_bindings: Vec<ActionBindingSummary>,
1582    behavior_bindings: Vec<BehaviorBindingSummary>,
1583    compiled_behavior_bindings: Vec<CompiledBehaviorBinding>,
1584}
1585
1586#[derive(Debug, Clone)]
1587struct MatchedBehaviorTarget {
1588    device_id: String,
1589    point_id: String,
1590}
1591
1592fn point_catalog_key(device_id: &str, point_id: &str) -> String {
1593    format!("{}/{}", device_id, point_id)
1594}
1595
1596fn matching_behavior_targets(
1597    behavior: &BehaviorDefinition,
1598    profile: &SimulatorProfile,
1599) -> Vec<MatchedBehaviorTarget> {
1600    let mut matches = Vec::new();
1601    for unit in &profile.units {
1602        let device_id = format!("modbus-{}", unit.unit_id);
1603        if let Some(expected_device) = &behavior.target.device_id {
1604            if &device_id != expected_device {
1605                continue;
1606            }
1607        }
1608        if let Some(expected_unit) = behavior.target.unit_id {
1609            if unit.unit_id != expected_unit {
1610                continue;
1611            }
1612        }
1613        if unit
1614            .points
1615            .iter()
1616            .any(|point| point.id == behavior.target.point_id)
1617        {
1618            matches.push(MatchedBehaviorTarget {
1619                device_id,
1620                point_id: behavior.target.point_id.clone(),
1621            });
1622        }
1623    }
1624    matches
1625}
1626
1627/// Typed schema surface used by `inspect modbus-schema`.
1628#[derive(Debug, Clone, Serialize)]
1629pub struct ModbusSchemaSummary {
1630    pub kind: &'static str,
1631    pub formats: Vec<&'static str>,
1632    pub top_level_sections: Vec<SchemaSection>,
1633    pub commands: Vec<&'static str>,
1634    pub notes: Vec<&'static str>,
1635}
1636
1637/// Schema description for a top-level section.
1638#[derive(Debug, Clone, Serialize)]
1639pub struct SchemaSection {
1640    pub name: &'static str,
1641    pub purpose: &'static str,
1642    pub required: bool,
1643}
1644
1645/// Returns the DX-oriented schema summary for Modbus config files.
1646pub fn schema_summary() -> ModbusSchemaSummary {
1647    ModbusSchemaSummary {
1648        kind: "modbus_simulator_config",
1649        formats: vec!["yaml", "json", "toml"],
1650        top_level_sections: vec![
1651            SchemaSection {
1652                name: "defaults",
1653                purpose: "Shared trace and readiness defaults used during session compilation",
1654                required: false,
1655            },
1656            SchemaSection {
1657                name: "transports",
1658                purpose: "Named TCP or RTU endpoints selected by sessions",
1659                required: true,
1660            },
1661            SchemaSection {
1662                name: "datastores",
1663                purpose: "Named dense or sparse datastore definitions reused by devices and presets",
1664                required: false,
1665            },
1666            SchemaSection {
1667                name: "devices",
1668                purpose: "Named unit bundles with datastore refs, point catalogs, timing, and tags",
1669                required: false,
1670            },
1671            SchemaSection {
1672                name: "actions",
1673                purpose: "Deterministic built-in action catalog referenced by point bindings",
1674                required: false,
1675            },
1676            SchemaSection {
1677                name: "behaviors",
1678                purpose: "Deterministic behavior graph referencing actions, triggers, conditions, and point targets",
1679                required: false,
1680            },
1681            SchemaSection {
1682                name: "sessions",
1683                purpose: "Canonical run targets selecting transport, devices, control defaults, trace, reset, fault presets, response profiles, and behavior sets",
1684                required: true,
1685            },
1686            SchemaSection {
1687                name: "response_profiles",
1688                purpose: "Named wire-level response behaviors compiled into runtime fault policies",
1689                required: false,
1690            },
1691            SchemaSection {
1692                name: "presets",
1693                purpose: "Quickstart generators that compile to generated profiles",
1694                required: false,
1695            },
1696        ],
1697        commands: vec![
1698            "mabi validate modbus-config <file>",
1699            "mabi inspect modbus-config <file>",
1700            "mabi serve modbus --config <file> --session <name>",
1701            "mabi control modbus ...",
1702        ],
1703        notes: vec![
1704            "Config files are source-of-truth and stay file-backed",
1705            "Runtime mutations do not rewrite config files",
1706            "Named fault presets are scoped to a session definition",
1707            "Deterministic actions, behaviors, and response profiles compile into immutable session metadata",
1708        ],
1709    }
1710}
1711
1712#[cfg(test)]
1713mod tests {
1714    use std::collections::BTreeMap;
1715
1716    use super::{
1717        default_trace_capacity, CompiledModbusSession, CompiledTransportKind, DatastoreDefinition,
1718        DatastoreSelector, GeneratedPresetDefinition, ModbusSimulatorConfig,
1719        ResponseProfileDefinition, SessionControlConfig, SessionDefinition, SessionTraceConfig,
1720        SimulatorDefaults, TransportDefinition,
1721    };
1722    use crate::fault_injection::FaultInjectionConfig;
1723    use crate::profile::SimulatorProfile;
1724
1725    #[test]
1726    fn config_parses_yaml_and_compiles_session() {
1727        let config = ModbusSimulatorConfig::from_str_with_format(
1728            r#"
1729defaults:
1730  trace_capacity: 128
1731transports:
1732  local:
1733    kind: tcp
1734    bind: 127.0.0.1
1735    port: 1502
1736devices:
1737  plant:
1738    units:
1739      - unit_id: 1
1740        name: Pump
1741sessions:
1742  demo:
1743    transport: local
1744    devices: [plant]
1745"#,
1746            "yaml",
1747        )
1748        .unwrap();
1749
1750        let compiled = config.compile_session("demo").unwrap();
1751        assert_eq!(compiled.session_name, "demo");
1752        assert!(compiled.launch.config["profile"]["units"].is_array());
1753        assert_eq!(compiled.trace.buffer_capacity(), 128);
1754    }
1755
1756    #[test]
1757    fn config_rejects_unknown_transport_reference() {
1758        let config = ModbusSimulatorConfig {
1759            sessions: BTreeMap::from([(
1760                "broken".to_string(),
1761                SessionDefinition {
1762                    transport: "missing".to_string(),
1763                    service_name: None,
1764                    devices: vec![],
1765                    preset: Some("default".to_string()),
1766                    trace: SessionTraceConfig::default(),
1767                    reset: Default::default(),
1768                    control: Default::default(),
1769                    fault_presets: BTreeMap::new(),
1770                    active_fault_preset: None,
1771                    active_response_profile: None,
1772                    behavior_sets: BTreeMap::new(),
1773                    active_behavior_set: None,
1774                    readiness_timeout_ms: None,
1775                },
1776            )]),
1777            presets: BTreeMap::from([(
1778                "default".to_string(),
1779                GeneratedPresetDefinition {
1780                    devices: 1,
1781                    points_per_device: 4,
1782                    datastore: None,
1783                },
1784            )]),
1785            ..Default::default()
1786        };
1787
1788        let error = config.validate().unwrap_err().to_string();
1789        assert!(error.contains("unknown transport"));
1790    }
1791
1792    #[test]
1793    fn compiled_session_runtime_extensions_include_active_fault_preset() {
1794        let compiled = CompiledModbusSession {
1795            session_name: "demo".into(),
1796            launch: mabi_runtime::ProtocolLaunchSpec {
1797                protocol: "modbus".into(),
1798                name: Some("demo".into()),
1799                config: serde_json::json!({}),
1800            },
1801            transport_kind: CompiledTransportKind::Tcp,
1802            profile: SimulatorProfile::new(),
1803            trace: SessionTraceConfig {
1804                enabled: true,
1805                capacity: Some(default_trace_capacity()),
1806            },
1807            reset: Default::default(),
1808            control: SessionControlConfig::default(),
1809            fault_presets: BTreeMap::from([("delay".into(), FaultInjectionConfig::default())]),
1810            active_fault_preset: Some("delay".into()),
1811            response_profiles: BTreeMap::new(),
1812            active_response_profile: None,
1813            actions: BTreeMap::new(),
1814            behaviors: BTreeMap::new(),
1815            behavior_sets: BTreeMap::new(),
1816            active_behavior_set: None,
1817            point_catalog: BTreeMap::new(),
1818            datastore_policies: Vec::new(),
1819            action_binding_summaries: Vec::new(),
1820            behavior_binding_summaries: Vec::new(),
1821            compiled_behavior_bindings: Vec::new(),
1822            readiness_timeout_ms: None,
1823        };
1824
1825        let extensions = compiled.runtime_extensions();
1826        assert!(extensions.protocol_config("modbus").is_some());
1827    }
1828
1829    #[test]
1830    fn generated_preset_can_override_datastore_kind() {
1831        let preset = GeneratedPresetDefinition {
1832            devices: 1,
1833            points_per_device: 4,
1834            datastore: Some(DatastoreSelector::Inline(DatastoreDefinition::from(
1835                crate::profile::DatastoreKind::Sparse {
1836                    config: Default::default(),
1837                },
1838            ))),
1839        };
1840
1841        let profile = preset.build(&BTreeMap::new()).unwrap();
1842        assert!(matches!(
1843            profile.units[0].datastore,
1844            crate::profile::DatastoreKind::Sparse { .. }
1845        ));
1846    }
1847
1848    #[test]
1849    fn compiled_session_runtime_extensions_include_active_response_profile_faults() {
1850        let compiled = CompiledModbusSession {
1851            session_name: "demo".into(),
1852            launch: mabi_runtime::ProtocolLaunchSpec {
1853                protocol: "modbus".into(),
1854                name: Some("demo".into()),
1855                config: serde_json::json!({}),
1856            },
1857            transport_kind: CompiledTransportKind::Tcp,
1858            profile: SimulatorProfile::new(),
1859            trace: SessionTraceConfig::default(),
1860            reset: Default::default(),
1861            control: SessionControlConfig::default(),
1862            fault_presets: BTreeMap::new(),
1863            active_fault_preset: None,
1864            response_profiles: BTreeMap::from([(
1865                "slow".into(),
1866                ResponseProfileDefinition {
1867                    delay_ms: Some(25),
1868                    ..Default::default()
1869                },
1870            )]),
1871            active_response_profile: Some("slow".into()),
1872            actions: BTreeMap::new(),
1873            behaviors: BTreeMap::new(),
1874            behavior_sets: BTreeMap::new(),
1875            active_behavior_set: None,
1876            point_catalog: BTreeMap::new(),
1877            datastore_policies: Vec::new(),
1878            action_binding_summaries: Vec::new(),
1879            behavior_binding_summaries: Vec::new(),
1880            compiled_behavior_bindings: Vec::new(),
1881            readiness_timeout_ms: None,
1882        };
1883
1884        let extensions = compiled.runtime_extensions();
1885        let config = extensions.protocol_config("modbus").unwrap();
1886        assert_eq!(
1887            config["fault_injection"]["faults"][0]["type"],
1888            "delayed_response"
1889        );
1890    }
1891
1892    #[test]
1893    fn named_datastore_references_compile_into_profiles() {
1894        let config = ModbusSimulatorConfig::from_str_with_format(
1895            r#"
1896transports:
1897  local:
1898    kind: tcp
1899    bind: 127.0.0.1
1900    port: 1502
1901datastores:
1902  sparse_lab:
1903    kind: sparse
1904devices:
1905  lab:
1906    units:
1907      - unit_id: 7
1908        name: TestBench
1909        datastore: sparse_lab
1910sessions:
1911  demo:
1912    transport: local
1913    devices: [lab]
1914"#,
1915            "yaml",
1916        )
1917        .unwrap();
1918
1919        let compiled = config.compile_session("demo").unwrap();
1920        assert!(matches!(
1921            compiled.profile.units[0].datastore,
1922            crate::profile::DatastoreKind::Sparse { .. }
1923        ));
1924    }
1925
1926    #[test]
1927    fn config_compiles_action_bindings_and_point_catalog_metadata() {
1928        let config = ModbusSimulatorConfig::from_str_with_format(
1929            r#"
1930transports:
1931  local:
1932    kind: tcp
1933    bind: 127.0.0.1
1934    port: 1502
1935datastores:
1936  sparse_named:
1937    kind: sparse
1938    readonly_ranges:
1939      - register_type: holding_register
1940        start: 5
1941        quantity: 1
1942    invalid_ranges:
1943      - register_type: holding_register
1944        start: 8
1945        quantity: 1
1946actions:
1947  clamp_temp:
1948    kind: clamp
1949    min: 0
1950    max: 100
1951response_profiles:
1952  slow:
1953    delay_ms: 10
1954devices:
1955  plant:
1956    units:
1957      - unit_id: 1
1958        name: Pump
1959        datastore: sparse_named
1960        points:
1961          - id: temperature
1962            name: Temperature
1963            register_type: holding_register
1964            address: 5
1965            data_type: uint16
1966        action_bindings:
1967          - point_id: temperature
1968            bindings:
1969              - action: clamp_temp
1970                trigger: on_write
1971sessions:
1972  demo:
1973    transport: local
1974    devices: [plant]
1975    active_response_profile: slow
1976"#,
1977            "yaml",
1978        )
1979        .unwrap();
1980
1981        let compiled = config.compile_session("demo").unwrap();
1982        let metadata = compiled
1983            .point_metadata("modbus-1", "temperature")
1984            .expect("compiled point metadata");
1985        assert_eq!(metadata.source_datastore.as_deref(), Some("sparse_named"));
1986        assert!(metadata.read_only);
1987        assert_eq!(metadata.action_bindings, vec!["clamp_temp@on_write"]);
1988        assert_eq!(compiled.active_response_profile.as_deref(), Some("slow"));
1989    }
1990
1991    #[test]
1992    fn schema_defaults_are_stable() {
1993        let defaults = SimulatorDefaults::default();
1994        assert_eq!(defaults.trace_capacity, default_trace_capacity());
1995        assert!(matches!(
1996            TransportDefinition::Tcp {
1997                bind: "0.0.0.0".into(),
1998                port: 502,
1999                performance_preset: None,
2000            },
2001            TransportDefinition::Tcp { .. }
2002        ));
2003    }
2004}