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. A connection failure means nothing
10/// was attempted; an operation failure may be worth retrying; an unsupported operation cannot
11/// succeed on retry.
12///
13/// The underlying cause is boxed (`Box<dyn std::error::Error + Send + Sync>`) rather than
14/// naming a concrete type, since different [`crate::Driver`] implementations wrap
15/// unrelated error types (`opcda_bridge::Error`, a simulator's internal error, or replay
16/// fixture parse errors) and this trait/error model must not force all of them to share one.
17/// `#[source]` still preserves the full chain for `std::error::Error::source()`/`anyhow`/
18/// logging to walk.
19#[derive(Debug, thiserror::Error)]
20pub enum DriverError {
21    /// The driver could not be reached at all — nothing was read or written. For
22    /// [`crate::opcda::OpcDaDriver`], this wraps `opcda_bridge::Error::Connect`.
23    #[error("failed to connect to driver")]
24    Connect(#[source] Box<dyn std::error::Error + Send + Sync>),
25
26    /// A `read`/`write`/`browse` call reached the driver but failed there — an RPC error,
27    /// an unresolvable tag, a malformed response. For [`crate::opcda::OpcDaDriver`], this
28    /// wraps `opcda_bridge::Error::Rpc`. Distinct from a rejected-but-otherwise-successful
29    /// write (see [`crate::types::WriteOutcome`]), which is not an error at all.
30    #[error("driver operation failed")]
31    Operation(#[source] Box<dyn std::error::Error + Send + Sync>),
32
33    /// One specific tag's value could not be used as requested — e.g. its raw string value
34    /// isn't valid for whatever the caller needed it to mean. Carries the tag so a caller
35    /// reporting a failed run can name exactly which tag was the problem.
36    #[error("tag '{tag}': {message}")]
37    InvalidTagValue { tag: TagId, message: String },
38
39    /// This driver does not implement the requested operation at all (e.g. `browse` on the
40    /// simulator or replay drivers, which have no real tag tree to browse) — distinct from
41    /// a transient [`DriverError::Operation`] failure, since retrying can never help.
42    #[error("'{operation}' is not supported by this driver")]
43    Unsupported { operation: &'static str },
44
45    /// The gateway rejected an indexed-search operation because the server's current
46    /// enrollment or index state does not permit it.
47    #[error("indexed-search operation rejected: {message}")]
48    IndexOperationRejected { message: String },
49
50    /// The connected gateway predates a required protocol operation.
51    #[error(
52        "gateway does not support {operation}; upgrade the OPC DA bridge gateway to a compatible version"
53    )]
54    IncompatibleGateway { operation: &'static str },
55
56    /// A server-side browse session or continuation token is no longer usable.
57    #[error("browse session or cursor is no longer valid; reopen the tag browser")]
58    BrowseStateInvalid,
59}
60
61/// A `Result` alias for [`DriverError`], mirroring the ergonomics of `opcda_bridge::Result`
62/// (and, further down, `sqlx`-style crate-local aliases already used by `bhtune-db`).
63pub type DriverResult<T> = std::result::Result<T, DriverError>;
64
65/// A mutex whose owner panicked while holding the guard.
66#[derive(Debug)]
67struct PoisonedLock {
68    detail: String,
69}
70
71impl std::fmt::Display for PoisonedLock {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        let detail = &self.detail;
74        write!(f, "mutex poisoned: {detail}")
75    }
76}
77
78impl std::error::Error for PoisonedLock {
79    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
80        None
81    }
82}
83
84/// Returns the mutex guard, or [`DriverError::Operation`] if the mutex is poisoned.
85///
86/// A poisoned lock means another task panicked while holding it. Recovering the inner
87/// guard could observe a half-updated simulator or replay cursor, so the caller gets an
88/// error it can restore from instead of panicking on the same path.
89pub(crate) fn poisoned_lock<T>(lock: std::sync::LockResult<T>) -> DriverResult<T> {
90    lock.map_err(|poisoned| {
91        DriverError::Operation(Box::new(PoisonedLock {
92            detail: poisoned.to_string(),
93        }))
94    })
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use std::io;
101
102    #[test]
103    fn connect_error_displays_without_source_text_but_keeps_source_chain() {
104        let source = io::Error::other("refused");
105        let err = DriverError::Connect(Box::new(source));
106        assert_eq!(err.to_string(), "failed to connect to driver");
107        assert!(std::error::Error::source(&err).is_some());
108    }
109
110    #[test]
111    fn operation_error_displays_without_source_text_but_keeps_source_chain() {
112        let source = io::Error::other("timed out");
113        let err = DriverError::Operation(Box::new(source));
114        assert_eq!(err.to_string(), "driver operation failed");
115        assert!(std::error::Error::source(&err).is_some());
116    }
117
118    #[test]
119    fn invalid_tag_value_names_the_tag_and_reason() {
120        let err = DriverError::InvalidTagValue {
121            tag: "Area1.LIC101.MODE".to_string(),
122            message: "unrecognized mode code 'FOO'".to_string(),
123        };
124        assert_eq!(
125            err.to_string(),
126            "tag 'Area1.LIC101.MODE': unrecognized mode code 'FOO'"
127        );
128    }
129
130    #[test]
131    fn unsupported_names_the_operation() {
132        let err = DriverError::Unsupported {
133            operation: "browse",
134        };
135        assert_eq!(err.to_string(), "'browse' is not supported by this driver");
136    }
137
138    #[test]
139    fn incompatible_gateway_names_the_upgrade_action() {
140        let err = DriverError::IncompatibleGateway {
141            operation: "paged browse",
142        };
143        assert!(
144            err.to_string()
145                .contains("upgrade the OPC DA bridge gateway")
146        );
147    }
148
149    #[test]
150    fn indexed_search_rejection_names_gateway_reason() {
151        let err = DriverError::IndexOperationRejected {
152            message: "server is not enrolled for namespace indexing".to_string(),
153        };
154        assert_eq!(
155            err.to_string(),
156            "indexed-search operation rejected: server is not enrolled for namespace indexing"
157        );
158    }
159
160    #[test]
161    fn invalid_browse_state_is_actionable() {
162        assert!(
163            DriverError::BrowseStateInvalid
164                .to_string()
165                .contains("reopen the tag browser")
166        );
167    }
168
169    #[test]
170    fn poisoned_lock_returns_the_guard_or_an_operation_error() {
171        let live = std::sync::Mutex::new(1);
172        let guard = poisoned_lock(live.lock()).unwrap();
173        assert_eq!(*guard, 1);
174        drop(guard);
175
176        let poisoned = std::sync::Mutex::new(0);
177        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
178            let _guard = poisoned.lock().unwrap();
179            panic!("poison the mutex");
180        }));
181        let err = poisoned_lock(poisoned.lock()).unwrap_err();
182        let source = std::error::Error::source(&err).expect("operation error has a source");
183        assert!(source.to_string().contains("mutex poisoned"));
184        assert!(std::error::Error::source(source).is_none());
185    }
186}