Skip to main content

bhtune_db/
convert.rs

1//! Converts `bhtune-core`'s `serde`-tagged enums to/from the plain TEXT values SQLite stores
2//! them as.
3//!
4//! `bhtune-core` deliberately has zero non-`serde` dependencies (see its crate docs), so it
5//! cannot derive `sqlx::Type` itself, and Rust's orphan rules mean `bhtune-db` cannot
6//! implement a foreign trait (`sqlx::Type`) for a foreign type (e.g.
7//! `bhtune_core::ProcessType`) either. Rather than defining a parallel `sqlx`-aware enum for
8//! every `bhtune-core` enum (eight of them, and rising), [`enum_to_text`] and
9//! [`text_to_enum`] reuse each enum's existing `#[serde(rename_all = "snake_case")]`
10//! implementation as the single source of truth for its wire form — the same string a
11//! `ProcessType` would serialize to over the CLI's `--output json` or the web GUI's HTTP API
12//! already matches what gets stored in a `TEXT` column.
13
14use serde::{Serialize, de::DeserializeOwned};
15
16use crate::error::{DbError, DbResult};
17
18/// A serialized value that was not the bare JSON string a fieldless enum must produce.
19#[derive(Debug)]
20struct EnumShapeError(String);
21
22impl std::fmt::Display for EnumShapeError {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        let detail = &self.0;
25        write!(
26            f,
27            "enum_to_text called on a type that doesn't serialize to a bare string, got: {detail}"
28        )
29    }
30}
31
32impl std::error::Error for EnumShapeError {}
33
34/// Encodes a fieldless, `serde`-tagged enum as the bare string SQLite stores it as.
35///
36/// Returns [`DbError::Serialize`] if `T` does not serialize to a bare JSON string. Every
37/// `bhtune-core` enum stored in the database satisfies that shape; a failure here means a
38/// new type was wired into a TEXT column without checking that assumption first, and must be
39/// reported instead of panicking while a live loop may already be in manual.
40pub fn enum_to_text<T: Serialize>(value: &T) -> DbResult<String> {
41    match serde_json::to_value(value) {
42        Ok(serde_json::Value::String(text)) => Ok(text),
43        Ok(other) => Err(serialize_error("enum", EnumShapeError(other.to_string()))),
44        Err(source) => Err(serialize_error("enum", source)),
45    }
46}
47
48fn serialize_error(
49    context: &'static str,
50    source: impl std::error::Error + Send + Sync + 'static,
51) -> DbError {
52    DbError::Serialize {
53        context,
54        source: Box::new(source),
55    }
56}
57
58/// Serializes `value` for a JSON column, naming `context` in the error if that fails.
59pub(crate) fn json_text<T: Serialize>(context: &'static str, value: &T) -> DbResult<String> {
60    serde_json::to_string(value).map_err(|source| serialize_error(context, source))
61}
62
63/// Encodes an optional fieldless enum, preserving `None` without serializing it.
64pub(crate) fn option_enum_text<T: Serialize>(value: Option<&T>) -> DbResult<Option<String>> {
65    let Some(value) = value else {
66        return Ok(None);
67    };
68    Ok(Some(enum_to_text(value)?))
69}
70
71/// Decodes a value read from `column` back into a fieldless, `serde`-tagged enum.
72///
73/// Only fails if `value` doesn't match any of `T`'s variants — which the migration's `CHECK`
74/// constraint on every enum-shaped column should make unreachable in practice. The SQLite
75/// file can still be modified outside this crate, so values are validated when decoded.
76pub fn text_to_enum<T: DeserializeOwned>(column: &'static str, value: &str) -> DbResult<T> {
77    serde_json::from_value(enum_text_value(value)).map_err(|_| invalid_enum_value(column, value))
78}
79
80fn enum_text_value(value: &str) -> serde_json::Value {
81    serde_json::Value::String(value.to_string())
82}
83
84fn invalid_enum_value(column: &'static str, value: &str) -> DbError {
85    DbError::InvalidEnumValue {
86        column,
87        value: value.to_string(),
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::models::{
95        MvActuationKind, MvActuationStatus, RestoreStatus, RollbackState, SampleQuality,
96        TemplateOrigin, TuneDriver, TuneOutcome, WriteKind,
97    };
98    use bhtune_core::{
99        ControllerDirection, ControllerType, DerivativeType, IntegralType, ProcessType,
100        ProportionalType, ResponseLevel, TimeUnit,
101    };
102
103    /// Round-trips every variant of every `bhtune-core` enum stored in the database, and
104    /// pins the exact literal each one produces. These literals must stay in sync with the
105    /// `CHECK (... IN (...))` lists in `migrations/0001_initial.sql` — this test is
106    /// the one place both are written down side by side, so a rename on either side is easy
107    /// to spot and fix in the other.
108    #[test]
109    fn process_type_round_trips_and_matches_check_constraint() {
110        let cases = [
111            (ProcessType::Flow, "flow"),
112            (ProcessType::PressureLine, "pressure_line"),
113            (ProcessType::PressureVessel, "pressure_vessel"),
114            (ProcessType::Level, "level"),
115            (ProcessType::TemperatureMixing, "temperature_mixing"),
116            (
117                ProcessType::TemperatureHeatExchange,
118                "temperature_heat_exchange",
119            ),
120        ];
121        for (variant, text) in cases {
122            assert_eq!(enum_to_text(&variant).unwrap(), text);
123            assert_eq!(
124                text_to_enum::<ProcessType>("process_type", text).unwrap(),
125                variant
126            );
127        }
128    }
129
130    #[derive(Serialize)]
131    struct HasNan {
132        value: f32,
133    }
134
135    struct RejectsSerialize;
136
137    impl Serialize for RejectsSerialize {
138        fn serialize<S: serde::Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
139            Err(serde::ser::Error::custom("rejected"))
140        }
141    }
142
143    #[test]
144    fn enum_to_text_rejects_non_string_serialization() {
145        let err = enum_to_text(&42u8).unwrap_err();
146        assert!(
147            err.to_string()
148                .contains("doesn't serialize to a bare string")
149        );
150        assert!(std::error::Error::source(&err).is_some());
151    }
152
153    #[test]
154    fn enum_to_text_rejects_a_non_string_non_finite_value() {
155        let err = enum_to_text(&HasNan { value: f32::NAN }).unwrap_err();
156        assert!(err.to_string().contains("enum"));
157        assert!(std::error::Error::source(&err).is_some());
158    }
159
160    #[test]
161    fn enum_to_text_rejects_a_serializer_error() {
162        let err = enum_to_text(&RejectsSerialize).unwrap_err();
163        assert!(err.to_string().contains("enum"));
164        assert!(err.to_string().contains("rejected"));
165        assert!(std::error::Error::source(&err).is_some());
166    }
167
168    #[test]
169    fn json_text_rejects_a_serialization_failure() {
170        let err = json_text("timing metrics", &RejectsSerialize).unwrap_err();
171        assert!(err.to_string().contains("timing metrics"));
172        assert!(err.to_string().contains("rejected"));
173        assert!(std::error::Error::source(&err).is_some());
174    }
175
176    #[test]
177    fn option_enum_text_preserves_absence_and_encodes_presence() {
178        assert_eq!(option_enum_text::<ProcessType>(None).unwrap(), None);
179        assert_eq!(
180            option_enum_text(Some(&ProcessType::Flow))
181                .unwrap()
182                .as_deref(),
183            Some("flow")
184        );
185    }
186
187    #[test]
188    fn controller_type_round_trips_and_matches_check_constraint() {
189        let cases = [
190            (ControllerType::P, "p"),
191            (ControllerType::Pi, "pi"),
192            (ControllerType::Pid, "pid"),
193        ];
194        for (variant, text) in cases {
195            assert_eq!(enum_to_text(&variant).unwrap(), text);
196            assert_eq!(
197                text_to_enum::<ControllerType>("controller_type", text).unwrap(),
198                variant
199            );
200        }
201    }
202
203    #[test]
204    fn controller_direction_round_trips_and_matches_check_constraint() {
205        let cases = [
206            (ControllerDirection::Direct, "direct"),
207            (ControllerDirection::Reverse, "reverse"),
208        ];
209        for (variant, text) in cases {
210            assert_eq!(enum_to_text(&variant).unwrap(), text);
211            assert_eq!(
212                text_to_enum::<ControllerDirection>("controller_direction", text).unwrap(),
213                variant
214            );
215        }
216    }
217
218    #[test]
219    fn response_level_round_trips_and_matches_check_constraint() {
220        let cases = [
221            (ResponseLevel::Aggressive, "aggressive"),
222            (ResponseLevel::Moderate, "moderate"),
223            (ResponseLevel::Sluggish, "sluggish"),
224        ];
225        for (variant, text) in cases {
226            assert_eq!(enum_to_text(&variant).unwrap(), text);
227            assert_eq!(
228                text_to_enum::<ResponseLevel>("response_level", text).unwrap(),
229                variant
230            );
231        }
232    }
233
234    #[test]
235    fn proportional_type_round_trips_and_matches_check_constraint() {
236        let cases = [
237            (ProportionalType::Gain, "gain"),
238            (ProportionalType::Band, "band"),
239        ];
240        for (variant, text) in cases {
241            assert_eq!(enum_to_text(&variant).unwrap(), text);
242            assert_eq!(
243                text_to_enum::<ProportionalType>("proportional_type", text).unwrap(),
244                variant
245            );
246        }
247    }
248
249    #[test]
250    fn integral_type_round_trips_and_matches_check_constraint() {
251        let cases = [
252            (IntegralType::ResetTime, "reset_time"),
253            (IntegralType::ResetRate, "reset_rate"),
254            (IntegralType::ResetGain, "reset_gain"),
255        ];
256        for (variant, text) in cases {
257            assert_eq!(enum_to_text(&variant).unwrap(), text);
258            assert_eq!(
259                text_to_enum::<IntegralType>("integral_type", text).unwrap(),
260                variant
261            );
262        }
263    }
264
265    #[test]
266    fn derivative_type_round_trips_and_matches_check_constraint() {
267        let cases = [
268            (DerivativeType::DerivativeTime, "derivative_time"),
269            (DerivativeType::DerivativeGain, "derivative_gain"),
270        ];
271        for (variant, text) in cases {
272            assert_eq!(enum_to_text(&variant).unwrap(), text);
273            assert_eq!(
274                text_to_enum::<DerivativeType>("derivative_type", text).unwrap(),
275                variant
276            );
277        }
278    }
279
280    #[test]
281    fn time_unit_round_trips_and_matches_check_constraint() {
282        let cases = [
283            (TimeUnit::Seconds, "seconds"),
284            (TimeUnit::Minutes, "minutes"),
285        ];
286        for (variant, text) in cases {
287            assert_eq!(enum_to_text(&variant).unwrap(), text);
288            assert_eq!(
289                text_to_enum::<TimeUnit>("integral_unit", text).unwrap(),
290                variant
291            );
292        }
293    }
294
295    #[test]
296    fn unrecognized_value_is_a_typed_error_not_a_panic() {
297        let err = text_to_enum::<ProcessType>("process_type", "not_a_real_variant").unwrap_err();
298        assert!(matches!(
299            err,
300            DbError::InvalidEnumValue {
301                column: "process_type",
302                value,
303            } if value == "not_a_real_variant"
304        ));
305    }
306
307    #[test]
308    fn database_enum_types_round_trip_through_the_shared_text_codec() {
309        assert_eq!(
310            text_to_enum::<TuneDriver>("driver", "opcda").unwrap(),
311            TuneDriver::Opcda
312        );
313        assert_eq!(
314            text_to_enum::<TuneOutcome>("outcome", "completed").unwrap(),
315            TuneOutcome::Completed
316        );
317        assert_eq!(
318            text_to_enum::<TemplateOrigin>("origin", "builtin").unwrap(),
319            TemplateOrigin::Builtin
320        );
321        assert_eq!(
322            text_to_enum::<RestoreStatus>("restore_status", "confirmed").unwrap(),
323            RestoreStatus::Confirmed
324        );
325        assert_eq!(
326            text_to_enum::<RollbackState>("rollback_state", "succeeded").unwrap(),
327            RollbackState::Succeeded
328        );
329        assert_eq!(
330            text_to_enum::<SampleQuality>("pv_quality", "good").unwrap(),
331            SampleQuality::Good
332        );
333        assert_eq!(
334            text_to_enum::<MvActuationKind>("kind", "relay").unwrap(),
335            MvActuationKind::Relay
336        );
337        assert_eq!(
338            text_to_enum::<MvActuationStatus>("status", "confirmed").unwrap(),
339            MvActuationStatus::Confirmed
340        );
341        assert_eq!(
342            text_to_enum::<WriteKind>("kind", "write").unwrap(),
343            WriteKind::Write
344        );
345    }
346}