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, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum ValueKind {
37 Number,
38 Integer,
39 Boolean,
40 Text,
41 Timestamp,
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "camelCase")]
49pub enum Value {
50 Number {
51 value: f64,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 unit: Option<String>,
54 },
55 Integer {
56 value: i64,
57 },
58 Boolean {
59 value: bool,
60 },
61 Text {
62 value: String,
63 },
64 Timestamp {
66 value: String,
67 },
68 Absent,
70}
71
72impl Value {
73 pub fn kind(&self) -> Option<ValueKind> {
76 match self {
77 Value::Number { .. } => Some(ValueKind::Number),
78 Value::Integer { .. } => Some(ValueKind::Integer),
79 Value::Boolean { .. } => Some(ValueKind::Boolean),
80 Value::Text { .. } => Some(ValueKind::Text),
81 Value::Timestamp { .. } => Some(ValueKind::Timestamp),
82 Value::Absent => None,
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct KeyValue {
91 pub label: String,
92 pub value: Value,
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct Column {
99 pub name: String,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub unit: Option<String>,
103 pub kind: ValueKind,
104}
105
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct Table {
110 pub columns: Vec<Column>,
111 pub rows: Vec<Vec<Value>>,
112}
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct LineSeries {
118 pub name: String,
119 pub points: Vec<[f64; 2]>,
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124#[serde(tag = "type", rename_all = "camelCase")]
125pub enum ChartData {
126 Bar {
129 categories: Vec<String>,
130 values: Vec<f64>,
131 },
132 Line { series: Vec<LineSeries> },
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct Chart {
143 pub x_label: String,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub x_unit: Option<String>,
146 pub y_label: String,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub y_unit: Option<String>,
149 pub data: ChartData,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(tag = "type", rename_all = "camelCase")]
155pub enum FragmentItem {
156 KeyValues {
157 entries: Vec<KeyValue>,
158 },
159 Table {
160 table: Table,
161 },
162 Note {
164 text: String,
165 },
166 Chart {
169 chart: Chart,
170 },
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176#[serde(rename_all = "camelCase")]
177pub struct Fragment {
178 pub title: String,
179 pub items: Vec<FragmentItem>,
180}
181
182#[derive(Debug, Clone, PartialEq)]
187pub enum BlockError {
188 UnknownBlock { id: String },
190 Unavailable { reason: String },
193 Failed { message: String },
195}
196
197impl std::fmt::Display for BlockError {
198 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199 match self {
200 BlockError::UnknownBlock { id } => write!(f, "unknown report block: {id:?}"),
201 BlockError::Unavailable { reason } => {
202 write!(f, "report block unavailable for this run: {reason}")
203 }
204 BlockError::Failed { message } => write!(f, "report block failed: {message}"),
205 }
206 }
207}
208
209impl std::error::Error for BlockError {}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn value_serde_wire_shape_is_stable() {
217 let v = Value::Number {
220 value: 1.5,
221 unit: Some("m".into()),
222 };
223 assert_eq!(
224 serde_json::to_string(&v).unwrap(),
225 r#"{"type":"number","value":1.5,"unit":"m"}"#
226 );
227 assert_eq!(
228 serde_json::to_string(&Value::Absent).unwrap(),
229 r#"{"type":"absent"}"#
230 );
231 }
232
233 #[test]
234 fn fragment_round_trips_through_json() {
235 let fragment = Fragment {
236 title: "Run Summary".into(),
237 items: vec![
238 FragmentItem::KeyValues {
239 entries: vec![KeyValue {
240 label: "Junctions".into(),
241 value: Value::Integer { value: 42 },
242 }],
243 },
244 FragmentItem::Table {
245 table: Table {
246 columns: vec![Column {
247 name: "Quantity".into(),
248 unit: None,
249 kind: ValueKind::Text,
250 }],
251 rows: vec![vec![Value::Text {
252 value: "Pressure".into(),
253 }]],
254 },
255 },
256 FragmentItem::Note {
257 text: "Sampled.".into(),
258 },
259 ],
260 };
261 let json = serde_json::to_string(&fragment).unwrap();
262 let back: Fragment = serde_json::from_str(&json).unwrap();
263 assert_eq!(back, fragment);
264 }
265
266 #[test]
267 fn chart_serde_wire_shape_is_stable() {
268 let chart = Chart {
269 x_label: "Minimum pressure".into(),
270 x_unit: Some("m".into()),
271 y_label: "Junctions".into(),
272 y_unit: None,
273 data: ChartData::Bar {
274 categories: vec!["0 – 14".into()],
275 values: vec![3.0],
276 },
277 };
278 assert_eq!(
279 serde_json::to_string(&FragmentItem::Chart {
280 chart: chart.clone()
281 })
282 .unwrap(),
283 r#"{"type":"chart","chart":{"xLabel":"Minimum pressure","xUnit":"m","yLabel":"Junctions","data":{"type":"bar","categories":["0 – 14"],"values":[3.0]}}}"#
284 );
285 let json = serde_json::to_string(&chart).unwrap();
286 assert_eq!(serde_json::from_str::<Chart>(&json).unwrap(), chart);
287 }
288
289 #[test]
290 fn value_kind_mapping() {
291 assert_eq!(Value::Integer { value: 1 }.kind(), Some(ValueKind::Integer));
292 assert_eq!(Value::Absent.kind(), None);
293 }
294
295 #[test]
296 fn block_error_messages_are_descriptive() {
297 let e = BlockError::Unavailable {
298 reason: "the run has no water-quality results".into(),
299 };
300 assert!(e.to_string().contains("no water-quality results"));
301 let e = BlockError::UnknownBlock {
302 id: "wds.nope".into(),
303 };
304 assert!(e.to_string().contains("wds.nope"));
305 }
306}