Skip to main content

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}