1use serde::{Serialize, de::DeserializeOwned};
15
16use crate::error::{DbError, DbResult};
17
18#[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
34pub 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
58pub(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
63pub(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
71pub 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 #[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}