Skip to main content

opcda_bridge/
types.rs

1//! Plain data types returned by [`crate::Client`]'s methods.
2//!
3//! These mirror the shapes `opcda-bridge-client`'s CLI row structs
4//! (`ServerRow`, `TagRow`, `ReadRow`, `WriteRow` in that crate's
5//! `commands.rs`) build from gRPC responses, but carry no presentation
6//! concerns — no `Tabled`, no `Serialize` — so depending on this crate never
7//! pulls `tabled` (or `clap`, `serde_json`, `toml`) in transitively.
8
9/// A single node returned by [`crate::Client::browse`]: one tag or branch in
10/// the OPC DA server's tag tree.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct BrowseNode {
13    pub tag_id: String,
14    pub node_type: String,
15}
16
17/// A single tag's value returned by [`crate::Client::read`].
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct TagValue {
20    pub tag_id: String,
21    pub value: String,
22    pub quality: String,
23    pub timestamp: String,
24}
25
26/// The result of a single [`crate::Client::write`] call.
27///
28/// `error` is `Option<String>` rather than collapsing "no error" and "an
29/// empty error string" into the same `""` value, the same distinction
30/// `opcda-bridge-client`'s own `WriteRow.error` makes (see that crate's
31/// `commands.rs`): whether the gateway reported an error at all is a fact
32/// about the RPC result itself, not a presentation choice, so it belongs
33/// here rather than being introduced only at the CLI's rendering layer.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct WriteResult {
36    pub tag_id: String,
37    pub success: bool,
38    pub error: Option<String>,
39}
40
41/// A tag value to write, parsed from a raw string via [`parse_value`].
42#[derive(Debug, Clone, PartialEq)]
43pub enum Value {
44    String(String),
45    Int(i32),
46    Float(f64),
47    Bool(bool),
48}
49
50/// Parse a raw string into the most specific [`Value`] variant it matches:
51/// `bool`, then `i32`, then `f64`, falling back to `String`.
52///
53/// Moved here from `opcda-bridge-client`'s `commands.rs` unchanged: this
54/// coercion was never CLI-specific, and any async Rust consumer of
55/// [`crate::Client::write`] (not only the CLI's `write` subcommand) needs
56/// the identical bool/int/float/string inference to turn a plain string
57/// into a typed [`Value`].
58pub fn parse_value(raw: &str) -> Value {
59    if let Ok(b) = raw.parse::<bool>() {
60        return Value::Bool(b);
61    }
62    if let Ok(i) = raw.parse::<i32>() {
63        return Value::Int(i);
64    }
65    if let Ok(f) = raw.parse::<f64>() {
66        return Value::Float(f);
67    }
68    Value::String(raw.to_string())
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn test_parse_value_bool_true() {
77        assert!(matches!(parse_value("true"), Value::Bool(true)));
78    }
79
80    #[test]
81    fn test_parse_value_bool_false() {
82        assert!(matches!(parse_value("false"), Value::Bool(false)));
83    }
84
85    #[test]
86    fn test_parse_value_int_positive() {
87        assert!(matches!(parse_value("42"), Value::Int(42)));
88    }
89
90    #[test]
91    fn test_parse_value_int_negative() {
92        assert!(matches!(parse_value("-1"), Value::Int(-1)));
93    }
94
95    #[test]
96    fn test_parse_value_int_zero() {
97        assert!(matches!(parse_value("0"), Value::Int(0)));
98    }
99
100    #[test]
101    fn test_parse_value_float_positive() {
102        assert!(matches!(parse_value("9.5"), Value::Float(v) if (v - 9.5).abs() < f64::EPSILON));
103    }
104
105    #[test]
106    fn test_parse_value_float_negative() {
107        assert!(matches!(parse_value("-2.5"), Value::Float(v) if (v + 2.5).abs() < f64::EPSILON));
108    }
109
110    #[test]
111    fn test_parse_value_float_exponential() {
112        assert!(matches!(parse_value("1e10"), Value::Float(v) if (v - 1e10).abs() < 1.0));
113    }
114
115    #[test]
116    fn test_parse_value_string_simple() {
117        assert!(matches!(parse_value("hello"), Value::String(s) if s == "hello"));
118    }
119
120    #[test]
121    fn test_parse_value_string_empty() {
122        assert!(matches!(parse_value(""), Value::String(s) if s.is_empty()));
123    }
124
125    #[test]
126    fn test_parse_value_string_numeric_string() {
127        assert!(matches!(parse_value("42foo"), Value::String(s) if s == "42foo"));
128    }
129
130    #[test]
131    fn test_parse_value_string_special_chars() {
132        assert!(matches!(parse_value("hello world!"), Value::String(s) if s == "hello world!"));
133    }
134
135    #[test]
136    fn test_browse_node_fields() {
137        let node = BrowseNode {
138            tag_id: "Simulink".into(),
139            node_type: "Branch".into(),
140        };
141        assert_eq!(node.tag_id, "Simulink");
142        assert_eq!(node.node_type, "Branch");
143    }
144
145    #[test]
146    fn test_tag_value_fields() {
147        let value = TagValue {
148            tag_id: "t1".into(),
149            value: "42".into(),
150            quality: "Good".into(),
151            timestamp: "now".into(),
152        };
153        assert_eq!(value.tag_id, "t1");
154        assert_eq!(value.value, "42");
155        assert_eq!(value.quality, "Good");
156        assert_eq!(value.timestamp, "now");
157    }
158
159    #[test]
160    fn test_write_result_success_has_no_error() {
161        let result = WriteResult {
162            tag_id: "t1".into(),
163            success: true,
164            error: None,
165        };
166        assert!(result.success);
167        assert_eq!(result.error, None);
168    }
169
170    #[test]
171    fn test_write_result_failure_carries_error() {
172        let result = WriteResult {
173            tag_id: "t1".into(),
174            success: false,
175            error: Some("access denied".into()),
176        };
177        assert!(!result.success);
178        assert_eq!(result.error, Some("access denied".to_string()));
179    }
180}