bhtune_driver/error.rs
1//! Errors a [`crate::Driver`] method can fail with.
2
3use crate::types::TagId;
4
5/// All the ways a [`crate::Driver`] call can fail.
6///
7/// Deliberately not a single opaque/`anyhow`-style error: callers need to tell "the driver
8/// itself is unreachable" apart from "one read/write/browse call failed" apart from "this
9/// driver doesn't support that at all" to react correctly — in particular the
10/// unattended-operation guardrails planned for `cli-safety` need to distinguish a connection
11/// failure (abort immediately, nothing was attempted) from an operation failure (may be worth
12/// one retry) rather than treating every failure identically.
13///
14/// The underlying cause is boxed (`Box<dyn std::error::Error + Send + Sync>`) rather than
15/// naming a concrete type, since different [`crate::Driver`] implementations wrap
16/// completely unrelated error types (a future `driver-opcda`'s `opcda_bridge::Error`, a
17/// simulator's own internal error, golden-trace parse errors for `driver-replay`) and this
18/// trait/error model must not force all of them to share one. `#[source]` still preserves the
19/// full chain for `std::error::Error::source()`/`anyhow`/logging to walk.
20#[derive(Debug, thiserror::Error)]
21pub enum DriverError {
22 /// The driver could not be reached at all — nothing was read or written. For
23 /// `driver-opcda`, this is expected to wrap `opcda_bridge::Error::Connect`.
24 #[error("failed to connect to driver")]
25 Connect(#[source] Box<dyn std::error::Error + Send + Sync>),
26
27 /// A `read`/`write`/`browse` call reached the driver but failed there — an RPC error,
28 /// an unresolvable tag, a malformed response. For `driver-opcda`, this is expected to
29 /// wrap `opcda_bridge::Error::Rpc`. Distinct from a rejected-but-otherwise-successful
30 /// write (see [`crate::types::WriteOutcome`]), which is not an error at all.
31 #[error("driver operation failed")]
32 Operation(#[source] Box<dyn std::error::Error + Send + Sync>),
33
34 /// One specific tag's value could not be used as requested — e.g. its raw string value
35 /// isn't valid for whatever the caller needed it to mean. Carries the tag so a caller
36 /// reporting a failed run can name exactly which tag was the problem.
37 #[error("tag '{tag}': {message}")]
38 InvalidTagValue { tag: TagId, message: String },
39
40 /// This driver does not implement the requested operation at all (e.g. `browse` on the
41 /// simulator or replay drivers, which have no real tag tree to browse) — distinct from
42 /// a transient [`DriverError::Operation`] failure, since retrying can never help.
43 #[error("'{operation}' is not supported by this driver")]
44 Unsupported { operation: &'static str },
45
46 /// The gateway rejected an indexed-search operation because the server's current
47 /// enrollment or index state does not permit it.
48 #[error("indexed-search operation rejected: {message}")]
49 IndexOperationRejected { message: String },
50
51 /// The connected gateway predates a required protocol operation.
52 #[error(
53 "gateway does not support {operation}; upgrade the OPC DA bridge gateway to a compatible version"
54 )]
55 IncompatibleGateway { operation: &'static str },
56
57 /// A server-side browse session or continuation token is no longer usable.
58 #[error("browse session or cursor is no longer valid; reopen the tag browser")]
59 BrowseStateInvalid,
60}
61
62/// A `Result` alias for [`DriverError`], mirroring the ergonomics of `opcda_bridge::Result`
63/// (and, further down, `sqlx`-style crate-local aliases already used by `bhtune-db`).
64pub type DriverResult<T> = std::result::Result<T, DriverError>;
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69 use std::io;
70
71 #[test]
72 fn connect_error_displays_without_source_text_but_keeps_source_chain() {
73 let source = io::Error::other("refused");
74 let err = DriverError::Connect(Box::new(source));
75 assert_eq!(err.to_string(), "failed to connect to driver");
76 assert!(std::error::Error::source(&err).is_some());
77 }
78
79 #[test]
80 fn operation_error_displays_without_source_text_but_keeps_source_chain() {
81 let source = io::Error::other("timed out");
82 let err = DriverError::Operation(Box::new(source));
83 assert_eq!(err.to_string(), "driver operation failed");
84 assert!(std::error::Error::source(&err).is_some());
85 }
86
87 #[test]
88 fn invalid_tag_value_names_the_tag_and_reason() {
89 let err = DriverError::InvalidTagValue {
90 tag: "Area1.LIC101.MODE".to_string(),
91 message: "unrecognized mode code 'FOO'".to_string(),
92 };
93 assert_eq!(
94 err.to_string(),
95 "tag 'Area1.LIC101.MODE': unrecognized mode code 'FOO'"
96 );
97 }
98
99 #[test]
100 fn unsupported_names_the_operation() {
101 let err = DriverError::Unsupported {
102 operation: "browse",
103 };
104 assert_eq!(err.to_string(), "'browse' is not supported by this driver");
105 }
106
107 #[test]
108 fn incompatible_gateway_names_the_upgrade_action() {
109 let err = DriverError::IncompatibleGateway {
110 operation: "paged browse",
111 };
112 assert!(
113 err.to_string()
114 .contains("upgrade the OPC DA bridge gateway")
115 );
116 }
117
118 #[test]
119 fn indexed_search_rejection_names_gateway_reason() {
120 let err = DriverError::IndexOperationRejected {
121 message: "server is not enrolled for namespace indexing".to_string(),
122 };
123 assert_eq!(
124 err.to_string(),
125 "indexed-search operation rejected: server is not enrolled for namespace indexing"
126 );
127 }
128
129 #[test]
130 fn invalid_browse_state_is_actionable() {
131 assert!(
132 DriverError::BrowseStateInvalid
133 .to_string()
134 .contains("reopen the tag browser")
135 );
136 }
137}