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}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct ChoiceItem {
41 pub value: String,
43 pub label: String,
45}
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53#[serde(tag = "type", rename_all = "camelCase")]
54pub enum OptionKind {
55 Number {
57 default: Option<f64>,
58 min: Option<f64>,
59 max: Option<f64>,
60 },
61 Integer {
63 default: Option<i64>,
64 min: Option<i64>,
65 max: Option<i64>,
66 },
67 Boolean {
68 default: Option<bool>,
69 },
70 Text {
71 default: Option<String>,
72 },
73 NumberList {
75 default: Option<Vec<f64>>,
76 min_len: Option<usize>,
78 ascending: bool,
80 },
81 Choice {
83 default: Option<String>,
84 items: Vec<ChoiceItem>,
85 },
86 MultiChoice {
88 default: Option<Vec<String>>,
89 items: Vec<ChoiceItem>,
90 },
91}
92
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct OptionDescriptor {
102 pub key: String,
104 pub label: String,
106 pub help: String,
108 pub kind: OptionKind,
110 pub unit: Option<String>,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "lowercase")]
117pub enum ValueKind {
118 Number,
119 Integer,
120 Boolean,
121 Text,
122 Timestamp,
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129#[serde(tag = "type", rename_all = "camelCase")]
130pub enum Value {
131 Number {
132 value: f64,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 unit: Option<String>,
135 },
136 Integer {
137 value: i64,
138 },
139 Boolean {
140 value: bool,
141 },
142 Text {
143 value: String,
144 },
145 Timestamp {
147 value: String,
148 },
149 Absent,
151}
152
153impl Value {
154 pub fn kind(&self) -> Option<ValueKind> {
157 match self {
158 Value::Number { .. } => Some(ValueKind::Number),
159 Value::Integer { .. } => Some(ValueKind::Integer),
160 Value::Boolean { .. } => Some(ValueKind::Boolean),
161 Value::Text { .. } => Some(ValueKind::Text),
162 Value::Timestamp { .. } => Some(ValueKind::Timestamp),
163 Value::Absent => None,
164 }
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase")]
171pub struct KeyValue {
172 pub label: String,
173 pub value: Value,
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct Column {
180 pub name: String,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub unit: Option<String>,
184 pub kind: ValueKind,
185}
186
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct Table {
191 pub columns: Vec<Column>,
192 pub rows: Vec<Vec<Value>>,
193}
194
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct LineSeries {
199 pub name: String,
200 pub points: Vec<[f64; 2]>,
201}
202
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205#[serde(tag = "type", rename_all = "camelCase")]
206pub enum ChartData {
207 Bar {
210 categories: Vec<String>,
211 values: Vec<f64>,
212 },
213 Line { series: Vec<LineSeries> },
215}
216
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub struct Chart {
224 pub x_label: String,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub x_unit: Option<String>,
227 pub y_label: String,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub y_unit: Option<String>,
230 pub data: ChartData,
231}
232
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235#[serde(tag = "type", rename_all = "camelCase")]
236pub enum FragmentItem {
237 KeyValues {
238 entries: Vec<KeyValue>,
239 },
240 Table {
241 table: Table,
242 },
243 Note {
245 text: String,
246 },
247 Chart {
250 chart: Chart,
251 },
252}
253
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
257#[serde(rename_all = "camelCase")]
258pub struct Fragment {
259 pub title: String,
260 pub items: Vec<FragmentItem>,
261}
262
263#[derive(Debug, Clone, PartialEq)]
268pub enum BlockError {
269 UnknownBlock { id: String },
271 Unavailable { reason: String },
274 Failed { message: String },
276}
277
278impl std::fmt::Display for BlockError {
279 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 match self {
281 BlockError::UnknownBlock { id } => write!(f, "unknown report block: {id:?}"),
282 BlockError::Unavailable { reason } => {
283 write!(f, "report block unavailable for this run: {reason}")
284 }
285 BlockError::Failed { message } => write!(f, "report block failed: {message}"),
286 }
287 }
288}
289
290impl std::error::Error for BlockError {}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn value_serde_wire_shape_is_stable() {
298 let v = Value::Number {
301 value: 1.5,
302 unit: Some("m".into()),
303 };
304 assert_eq!(
305 serde_json::to_string(&v).unwrap(),
306 r#"{"type":"number","value":1.5,"unit":"m"}"#
307 );
308 assert_eq!(
309 serde_json::to_string(&Value::Absent).unwrap(),
310 r#"{"type":"absent"}"#
311 );
312 }
313
314 #[test]
315 fn fragment_round_trips_through_json() {
316 let fragment = Fragment {
317 title: "Run Summary".into(),
318 items: vec![
319 FragmentItem::KeyValues {
320 entries: vec![KeyValue {
321 label: "Junctions".into(),
322 value: Value::Integer { value: 42 },
323 }],
324 },
325 FragmentItem::Table {
326 table: Table {
327 columns: vec![Column {
328 name: "Quantity".into(),
329 unit: None,
330 kind: ValueKind::Text,
331 }],
332 rows: vec![vec![Value::Text {
333 value: "Pressure".into(),
334 }]],
335 },
336 },
337 FragmentItem::Note {
338 text: "Sampled.".into(),
339 },
340 ],
341 };
342 let json = serde_json::to_string(&fragment).unwrap();
343 let back: Fragment = serde_json::from_str(&json).unwrap();
344 assert_eq!(back, fragment);
345 }
346
347 #[test]
348 fn chart_serde_wire_shape_is_stable() {
349 let chart = Chart {
350 x_label: "Minimum pressure".into(),
351 x_unit: Some("m".into()),
352 y_label: "Junctions".into(),
353 y_unit: None,
354 data: ChartData::Bar {
355 categories: vec!["0 – 14".into()],
356 values: vec![3.0],
357 },
358 };
359 assert_eq!(
360 serde_json::to_string(&FragmentItem::Chart {
361 chart: chart.clone()
362 })
363 .unwrap(),
364 r#"{"type":"chart","chart":{"xLabel":"Minimum pressure","xUnit":"m","yLabel":"Junctions","data":{"type":"bar","categories":["0 – 14"],"values":[3.0]}}}"#
365 );
366 let json = serde_json::to_string(&chart).unwrap();
367 assert_eq!(serde_json::from_str::<Chart>(&json).unwrap(), chart);
368 }
369
370 #[test]
371 fn value_kind_mapping() {
372 assert_eq!(Value::Integer { value: 1 }.kind(), Some(ValueKind::Integer));
373 assert_eq!(Value::Absent.kind(), None);
374 }
375
376 #[test]
377 fn block_error_messages_are_descriptive() {
378 let e = BlockError::Unavailable {
379 reason: "the run has no water-quality results".into(),
380 };
381 assert!(e.to_string().contains("no water-quality results"));
382 let e = BlockError::UnknownBlock {
383 id: "wds.nope".into(),
384 };
385 assert!(e.to_string().contains("wds.nope"));
386 }
387}