1use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct BlockDescriptor {
25 pub id: &'static str,
27 pub title: &'static str,
29 pub summary: &'static str,
31 pub category: &'static str,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct ChoiceItem {
45 pub value: String,
47 pub label: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57#[serde(tag = "type", rename_all = "camelCase")]
58pub enum OptionKind {
59 Number {
61 default: Option<f64>,
62 min: Option<f64>,
63 max: Option<f64>,
64 },
65 Integer {
67 default: Option<i64>,
68 min: Option<i64>,
69 max: Option<i64>,
70 },
71 Boolean {
72 default: Option<bool>,
73 },
74 Text {
75 default: Option<String>,
76 },
77 NumberList {
79 default: Option<Vec<f64>>,
80 min_len: Option<usize>,
82 ascending: bool,
84 },
85 Choice {
87 default: Option<String>,
88 items: Vec<ChoiceItem>,
89 },
90 MultiChoice {
92 default: Option<Vec<String>>,
93 items: Vec<ChoiceItem>,
94 },
95}
96
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct OptionDescriptor {
106 pub key: String,
108 pub label: String,
110 pub help: String,
112 pub kind: OptionKind,
114 pub unit: Option<String>,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "lowercase")]
121pub enum ValueKind {
122 Number,
123 Integer,
124 Boolean,
125 Text,
126 Timestamp,
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133#[serde(tag = "type", rename_all = "camelCase")]
134pub enum Value {
135 Number {
136 value: f64,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 unit: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
145 quantity: Option<String>,
146 },
147 Integer {
148 value: i64,
149 },
150 Boolean {
151 value: bool,
152 },
153 Text {
154 value: String,
155 },
156 Timestamp {
158 value: String,
159 },
160 Absent,
162}
163
164impl Value {
165 pub fn kind(&self) -> Option<ValueKind> {
168 match self {
169 Value::Number { .. } => Some(ValueKind::Number),
170 Value::Integer { .. } => Some(ValueKind::Integer),
171 Value::Boolean { .. } => Some(ValueKind::Boolean),
172 Value::Text { .. } => Some(ValueKind::Text),
173 Value::Timestamp { .. } => Some(ValueKind::Timestamp),
174 Value::Absent => None,
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct KeyValue {
183 pub label: String,
184 pub value: Value,
185}
186
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct Column {
191 pub name: String,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub unit: Option<String>,
195 pub kind: ValueKind,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub quantity: Option<String>,
201}
202
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct Table {
207 pub columns: Vec<Column>,
208 pub rows: Vec<Vec<Value>>,
209}
210
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase")]
214pub struct LineSeries {
215 pub name: String,
216 pub points: Vec<[f64; 2]>,
217}
218
219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221#[serde(tag = "type", rename_all = "camelCase")]
222pub enum ChartData {
223 Bar {
226 categories: Vec<String>,
227 values: Vec<f64>,
228 },
229 Line { series: Vec<LineSeries> },
231}
232
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase")]
239pub struct Chart {
240 pub x_label: String,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub x_unit: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub x_quantity: Option<String>,
247 pub y_label: String,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub y_unit: Option<String>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub y_quantity: Option<String>,
253 pub data: ChartData,
254}
255
256#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258#[serde(tag = "type", rename_all = "camelCase")]
259pub enum FragmentItem {
260 KeyValues {
261 entries: Vec<KeyValue>,
262 },
263 Table {
264 table: Table,
265 },
266 Note {
268 text: String,
269 },
270 Chart {
273 chart: Chart,
274 },
275}
276
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase")]
281pub struct Fragment {
282 pub title: String,
283 pub items: Vec<FragmentItem>,
284}
285
286#[derive(Debug, Clone, PartialEq)]
291pub enum BlockError {
292 UnknownBlock { id: String },
294 Unavailable { reason: String },
297 Failed { message: String },
299}
300
301impl std::fmt::Display for BlockError {
302 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303 match self {
304 BlockError::UnknownBlock { id } => write!(f, "unknown report block: {id:?}"),
305 BlockError::Unavailable { reason } => {
306 write!(f, "report block unavailable for this run: {reason}")
307 }
308 BlockError::Failed { message } => write!(f, "report block failed: {message}"),
309 }
310 }
311}
312
313impl std::error::Error for BlockError {}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn value_serde_wire_shape_is_stable() {
321 let v = Value::Number {
324 value: 1.5,
325 unit: Some("m".into()),
326 quantity: None,
327 };
328 assert_eq!(
329 serde_json::to_string(&v).unwrap(),
330 r#"{"type":"number","value":1.5,"unit":"m"}"#
331 );
332 assert_eq!(
333 serde_json::to_string(&Value::Absent).unwrap(),
334 r#"{"type":"absent"}"#
335 );
336 }
337
338 #[test]
343 fn quantity_tags_are_additive_on_the_wire() {
344 let tagged = Value::Number {
345 value: 51.25,
346 unit: Some("m".into()),
347 quantity: Some("pressure".into()),
348 };
349 assert_eq!(
350 serde_json::to_string(&tagged).unwrap(),
351 r#"{"type":"number","value":51.25,"unit":"m","quantity":"pressure"}"#
352 );
353 let old: Value =
354 serde_json::from_str(r#"{"type":"number","value":1.5,"unit":"m"}"#).unwrap();
355 assert_eq!(
356 old,
357 Value::Number {
358 value: 1.5,
359 unit: Some("m".into()),
360 quantity: None,
361 }
362 );
363 }
364
365 #[test]
366 fn fragment_round_trips_through_json() {
367 let fragment = Fragment {
368 title: "Run Summary".into(),
369 items: vec![
370 FragmentItem::KeyValues {
371 entries: vec![KeyValue {
372 label: "Junctions".into(),
373 value: Value::Integer { value: 42 },
374 }],
375 },
376 FragmentItem::Table {
377 table: Table {
378 columns: vec![Column {
379 name: "Quantity".into(),
380 unit: None,
381 kind: ValueKind::Text,
382 quantity: None,
383 }],
384 rows: vec![vec![Value::Text {
385 value: "Pressure".into(),
386 }]],
387 },
388 },
389 FragmentItem::Note {
390 text: "Sampled.".into(),
391 },
392 ],
393 };
394 let json = serde_json::to_string(&fragment).unwrap();
395 let back: Fragment = serde_json::from_str(&json).unwrap();
396 assert_eq!(back, fragment);
397 }
398
399 #[test]
400 fn chart_serde_wire_shape_is_stable() {
401 let chart = Chart {
402 x_label: "Minimum pressure".into(),
403 x_unit: Some("m".into()),
404 x_quantity: None,
405 y_label: "Junctions".into(),
406 y_unit: None,
407 y_quantity: None,
408 data: ChartData::Bar {
409 categories: vec!["0 – 14".into()],
410 values: vec![3.0],
411 },
412 };
413 assert_eq!(
414 serde_json::to_string(&FragmentItem::Chart {
415 chart: chart.clone()
416 })
417 .unwrap(),
418 r#"{"type":"chart","chart":{"xLabel":"Minimum pressure","xUnit":"m","yLabel":"Junctions","data":{"type":"bar","categories":["0 – 14"],"values":[3.0]}}}"#
419 );
420 let json = serde_json::to_string(&chart).unwrap();
421 assert_eq!(serde_json::from_str::<Chart>(&json).unwrap(), chart);
422 }
423
424 #[test]
425 fn value_kind_mapping() {
426 assert_eq!(Value::Integer { value: 1 }.kind(), Some(ValueKind::Integer));
427 assert_eq!(Value::Absent.kind(), None);
428 }
429
430 #[test]
431 fn block_error_messages_are_descriptive() {
432 let e = BlockError::Unavailable {
433 reason: "the run has no water-quality results".into(),
434 };
435 assert!(e.to_string().contains("no water-quality results"));
436 let e = BlockError::UnknownBlock {
437 id: "wds.nope".into(),
438 };
439 assert!(e.to_string().contains("wds.nope"));
440 }
441}