Skip to main content

mabi_core/
device_builder.rs

1//! Device builder pattern for fluent device construction.
2//!
3//! This module provides a builder pattern for creating devices with
4//! a clean, chainable API that improves extensibility and maintainability.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use mabi_core::device_builder::{DeviceBuilder, MockDeviceBuilder};
10//!
11//! let device = MockDeviceBuilder::new("device-001", "Temperature Sensor")
12//!     .protocol(Protocol::ModbusTcp)
13//!     .description("Main building temperature sensor")
14//!     .metadata("location", "Building A, Floor 1")
15//!     .metadata("manufacturer", "ACME Sensors")
16//!     .add_point(DataPointDef::new("temp", "Temperature", DataType::Float32)
17//!         .with_units("°C")
18//!         .with_range(-40.0, 80.0))
19//!     .add_point(DataPointDef::new("humidity", "Humidity", DataType::Float32)
20//!         .with_units("%")
21//!         .with_range(0.0, 100.0))
22//!     .build()?;
23//! ```
24
25use std::collections::HashMap;
26
27use crate::config::DeviceConfig;
28use crate::error::{Error, Result};
29use crate::protocol::Protocol;
30use crate::tags::Tags;
31use crate::types::{Address, DataPointDef, DataType};
32
33/// Builder for creating device configurations.
34///
35/// This builder provides a fluent API for constructing `DeviceConfig`
36/// instances with validation at build time.
37#[derive(Debug, Clone)]
38pub struct DeviceConfigBuilder {
39    id: String,
40    name: String,
41    description: String,
42    protocol: Protocol,
43    address: Option<String>,
44    points: Vec<DataPointDef>,
45    metadata: HashMap<String, String>,
46    tags: Tags,
47    validation_enabled: bool,
48}
49
50impl DeviceConfigBuilder {
51    /// Create a new device configuration builder.
52    ///
53    /// # Arguments
54    ///
55    /// * `id` - Unique device identifier
56    /// * `name` - Human-readable device name
57    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
58        Self {
59            id: id.into(),
60            name: name.into(),
61            description: String::new(),
62            protocol: Protocol::ModbusTcp,
63            address: None,
64            points: Vec::new(),
65            metadata: HashMap::new(),
66            tags: Tags::new(),
67            validation_enabled: true,
68        }
69    }
70
71    /// Set the protocol for this device.
72    pub fn protocol(mut self, protocol: Protocol) -> Self {
73        self.protocol = protocol;
74        self
75    }
76
77    /// Set the device description.
78    pub fn description(mut self, description: impl Into<String>) -> Self {
79        self.description = description.into();
80        self
81    }
82
83    /// Set the protocol-specific address.
84    pub fn address(mut self, address: impl Into<String>) -> Self {
85        self.address = Some(address.into());
86        self
87    }
88
89    /// Add a metadata key-value pair.
90    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
91        self.metadata.insert(key.into(), value.into());
92        self
93    }
94
95    /// Add multiple metadata entries.
96    pub fn with_metadata(mut self, metadata: HashMap<String, String>) -> Self {
97        self.metadata.extend(metadata);
98        self
99    }
100
101    /// Set tags for this device.
102    pub fn tags(mut self, tags: Tags) -> Self {
103        self.tags = tags;
104        self
105    }
106
107    /// Add a single tag (key-value pair).
108    pub fn tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
109        self.tags.insert(key.into(), value.into());
110        self
111    }
112
113    /// Add a label.
114    pub fn label(mut self, label: impl Into<String>) -> Self {
115        self.tags.add_label(label.into());
116        self
117    }
118
119    /// Add multiple labels.
120    pub fn labels<I, S>(mut self, labels: I) -> Self
121    where
122        I: IntoIterator<Item = S>,
123        S: Into<String>,
124    {
125        for label in labels {
126            self.tags.add_label(label.into());
127        }
128        self
129    }
130
131    /// Add a data point definition.
132    pub fn add_point(mut self, point: DataPointDef) -> Self {
133        self.points.push(point);
134        self
135    }
136
137    /// Add multiple data point definitions.
138    pub fn add_points(mut self, points: impl IntoIterator<Item = DataPointDef>) -> Self {
139        self.points.extend(points);
140        self
141    }
142
143    /// Disable validation at build time.
144    pub fn skip_validation(mut self) -> Self {
145        self.validation_enabled = false;
146        self
147    }
148
149    /// Validate the current builder state.
150    pub fn validate(&self) -> Result<()> {
151        if self.id.is_empty() {
152            return Err(Error::Config("Device ID cannot be empty".into()));
153        }
154        if self.name.is_empty() {
155            return Err(Error::Config("Device name cannot be empty".into()));
156        }
157
158        // Check for duplicate point IDs
159        let mut point_ids = std::collections::HashSet::new();
160        for point in &self.points {
161            if !point_ids.insert(&point.id) {
162                return Err(Error::Config(format!(
163                    "Duplicate data point ID: {}",
164                    point.id
165                )));
166            }
167        }
168
169        Ok(())
170    }
171
172    /// Build the device configuration.
173    pub fn build(self) -> Result<DeviceConfig> {
174        if self.validation_enabled {
175            self.validate()?;
176        }
177
178        Ok(DeviceConfig {
179            id: self.id,
180            name: self.name,
181            description: self.description,
182            protocol: self.protocol,
183            address: self.address,
184            points: self
185                .points
186                .into_iter()
187                .map(|p| crate::config::DataPointConfig {
188                    id: p.id,
189                    name: p.name,
190                    data_type: format!("{:?}", p.data_type).to_lowercase(),
191                    access: format!("{:?}", p.access).to_lowercase(),
192                    address: p.address.map(|a| format!("{:?}", a)),
193                    initial_value: p.default_value.map(|v| serde_json::to_value(v).unwrap()),
194                    units: p.units,
195                    min: p.min_value,
196                    max: p.max_value,
197                })
198                .collect(),
199            metadata: self.metadata,
200            tags: self.tags,
201        })
202    }
203}
204
205/// Builder for data point definitions with chainable API.
206#[derive(Debug, Clone)]
207pub struct DataPointBuilder {
208    id: String,
209    name: String,
210    data_type: DataType,
211    description: String,
212    access: crate::types::AccessMode,
213    units: Option<String>,
214    min_value: Option<f64>,
215    max_value: Option<f64>,
216    default_value: Option<crate::value::Value>,
217    address: Option<Address>,
218}
219
220impl DataPointBuilder {
221    /// Create a new data point builder.
222    pub fn new(id: impl Into<String>, name: impl Into<String>, data_type: DataType) -> Self {
223        Self {
224            id: id.into(),
225            name: name.into(),
226            data_type,
227            description: String::new(),
228            access: crate::types::AccessMode::ReadWrite,
229            units: None,
230            min_value: None,
231            max_value: None,
232            default_value: None,
233            address: None,
234        }
235    }
236
237    /// Set the description.
238    pub fn description(mut self, description: impl Into<String>) -> Self {
239        self.description = description.into();
240        self
241    }
242
243    /// Set the access mode.
244    pub fn access(mut self, access: crate::types::AccessMode) -> Self {
245        self.access = access;
246        self
247    }
248
249    /// Set read-only access.
250    pub fn read_only(mut self) -> Self {
251        self.access = crate::types::AccessMode::ReadOnly;
252        self
253    }
254
255    /// Set write-only access.
256    pub fn write_only(mut self) -> Self {
257        self.access = crate::types::AccessMode::WriteOnly;
258        self
259    }
260
261    /// Set the engineering units.
262    pub fn units(mut self, units: impl Into<String>) -> Self {
263        self.units = Some(units.into());
264        self
265    }
266
267    /// Set the value range.
268    pub fn range(mut self, min: f64, max: f64) -> Self {
269        self.min_value = Some(min);
270        self.max_value = Some(max);
271        self
272    }
273
274    /// Set the minimum value.
275    pub fn min(mut self, min: f64) -> Self {
276        self.min_value = Some(min);
277        self
278    }
279
280    /// Set the maximum value.
281    pub fn max(mut self, max: f64) -> Self {
282        self.max_value = Some(max);
283        self
284    }
285
286    /// Set the default value.
287    pub fn default_value(mut self, value: impl Into<crate::value::Value>) -> Self {
288        self.default_value = Some(value.into());
289        self
290    }
291
292    /// Set the protocol-specific address.
293    pub fn address(mut self, address: Address) -> Self {
294        self.address = Some(address);
295        self
296    }
297
298    /// Build the data point definition.
299    pub fn build(self) -> DataPointDef {
300        DataPointDef {
301            id: self.id,
302            name: self.name,
303            description: self.description,
304            data_type: self.data_type,
305            access: self.access,
306            units: self.units,
307            min_value: self.min_value,
308            max_value: self.max_value,
309            default_value: self.default_value,
310            address: self.address,
311        }
312    }
313}
314
315impl From<DataPointBuilder> for DataPointDef {
316    fn from(builder: DataPointBuilder) -> Self {
317        builder.build()
318    }
319}
320
321/// Shorthand function to create a new device config builder.
322pub fn device(id: impl Into<String>, name: impl Into<String>) -> DeviceConfigBuilder {
323    DeviceConfigBuilder::new(id, name)
324}
325
326/// Shorthand function to create a new data point builder.
327pub fn point(id: impl Into<String>, name: impl Into<String>, data_type: DataType) -> DataPointBuilder {
328    DataPointBuilder::new(id, name, data_type)
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn test_device_config_builder() {
337        let config = DeviceConfigBuilder::new("dev-001", "Test Device")
338            .protocol(Protocol::ModbusTcp)
339            .description("A test device")
340            .metadata("location", "Building A")
341            .add_point(
342                DataPointBuilder::new("temp", "Temperature", DataType::Float32)
343                    .units("°C")
344                    .range(-40.0, 80.0)
345                    .build(),
346            )
347            .build()
348            .unwrap();
349
350        assert_eq!(config.id, "dev-001");
351        assert_eq!(config.name, "Test Device");
352        assert_eq!(config.protocol, Protocol::ModbusTcp);
353        assert_eq!(config.points.len(), 1);
354    }
355
356    #[test]
357    fn test_device_config_builder_validation() {
358        // Empty ID should fail
359        let result = DeviceConfigBuilder::new("", "Test")
360            .build();
361        assert!(result.is_err());
362
363        // Empty name should fail
364        let result = DeviceConfigBuilder::new("test", "")
365            .build();
366        assert!(result.is_err());
367
368        // Duplicate point IDs should fail
369        let result = DeviceConfigBuilder::new("dev-001", "Test")
370            .add_point(DataPointBuilder::new("temp", "Temp 1", DataType::Float32).build())
371            .add_point(DataPointBuilder::new("temp", "Temp 2", DataType::Float32).build())
372            .build();
373        assert!(result.is_err());
374    }
375
376    #[test]
377    fn test_data_point_builder() {
378        let point = DataPointBuilder::new("temp", "Temperature", DataType::Float32)
379            .description("Room temperature")
380            .units("°C")
381            .range(-40.0, 80.0)
382            .read_only()
383            .default_value(25.0f64)
384            .build();
385
386        assert_eq!(point.id, "temp");
387        assert_eq!(point.units, Some("°C".to_string()));
388        assert_eq!(point.min_value, Some(-40.0));
389        assert_eq!(point.max_value, Some(80.0));
390        assert_eq!(point.access, crate::types::AccessMode::ReadOnly);
391    }
392
393    #[test]
394    fn test_shorthand_functions() {
395        let config = device("dev-001", "Test")
396            .protocol(Protocol::BacnetIp)
397            .add_point(point("temp", "Temperature", DataType::Float32).build())
398            .build()
399            .unwrap();
400
401        assert_eq!(config.id, "dev-001");
402        assert_eq!(config.protocol, Protocol::BacnetIp);
403    }
404
405    #[test]
406    fn test_device_config_builder_with_tags() {
407        let config = DeviceConfigBuilder::new("dev-001", "Tagged Device")
408            .protocol(Protocol::ModbusTcp)
409            .tag("location", "building-a")
410            .tag("floor", "3")
411            .label("hvac")
412            .label("critical")
413            .build()
414            .unwrap();
415
416        assert_eq!(config.tags.get("location"), Some("building-a"));
417        assert_eq!(config.tags.get("floor"), Some("3"));
418        assert!(config.tags.has_label("hvac"));
419        assert!(config.tags.has_label("critical"));
420    }
421
422    #[test]
423    fn test_device_config_builder_with_tags_object() {
424        let tags = Tags::new()
425            .with_tag("env", "prod")
426            .with_label("monitored");
427
428        let config = DeviceConfigBuilder::new("dev-002", "Device with Tags")
429            .tags(tags)
430            .build()
431            .unwrap();
432
433        assert_eq!(config.tags.get("env"), Some("prod"));
434        assert!(config.tags.has_label("monitored"));
435    }
436}