use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlockDescriptor {
pub id: &'static str,
pub title: &'static str,
pub summary: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ValueKind {
Number,
Integer,
Boolean,
Text,
Timestamp,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Value {
Number {
value: f64,
#[serde(skip_serializing_if = "Option::is_none")]
unit: Option<String>,
},
Integer {
value: i64,
},
Boolean {
value: bool,
},
Text {
value: String,
},
Timestamp {
value: String,
},
Absent,
}
impl Value {
pub fn kind(&self) -> Option<ValueKind> {
match self {
Value::Number { .. } => Some(ValueKind::Number),
Value::Integer { .. } => Some(ValueKind::Integer),
Value::Boolean { .. } => Some(ValueKind::Boolean),
Value::Text { .. } => Some(ValueKind::Text),
Value::Timestamp { .. } => Some(ValueKind::Timestamp),
Value::Absent => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KeyValue {
pub label: String,
pub value: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Column {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
pub kind: ValueKind,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Table {
pub columns: Vec<Column>,
pub rows: Vec<Vec<Value>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LineSeries {
pub name: String,
pub points: Vec<[f64; 2]>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ChartData {
Bar {
categories: Vec<String>,
values: Vec<f64>,
},
Line { series: Vec<LineSeries> },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Chart {
pub x_label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x_unit: Option<String>,
pub y_label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub y_unit: Option<String>,
pub data: ChartData,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum FragmentItem {
KeyValues {
entries: Vec<KeyValue>,
},
Table {
table: Table,
},
Note {
text: String,
},
Chart {
chart: Chart,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Fragment {
pub title: String,
pub items: Vec<FragmentItem>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BlockError {
UnknownBlock { id: String },
Unavailable { reason: String },
Failed { message: String },
}
impl std::fmt::Display for BlockError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BlockError::UnknownBlock { id } => write!(f, "unknown report block: {id:?}"),
BlockError::Unavailable { reason } => {
write!(f, "report block unavailable for this run: {reason}")
}
BlockError::Failed { message } => write!(f, "report block failed: {message}"),
}
}
}
impl std::error::Error for BlockError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn value_serde_wire_shape_is_stable() {
let v = Value::Number {
value: 1.5,
unit: Some("m".into()),
};
assert_eq!(
serde_json::to_string(&v).unwrap(),
r#"{"type":"number","value":1.5,"unit":"m"}"#
);
assert_eq!(
serde_json::to_string(&Value::Absent).unwrap(),
r#"{"type":"absent"}"#
);
}
#[test]
fn fragment_round_trips_through_json() {
let fragment = Fragment {
title: "Run Summary".into(),
items: vec![
FragmentItem::KeyValues {
entries: vec![KeyValue {
label: "Junctions".into(),
value: Value::Integer { value: 42 },
}],
},
FragmentItem::Table {
table: Table {
columns: vec![Column {
name: "Quantity".into(),
unit: None,
kind: ValueKind::Text,
}],
rows: vec![vec![Value::Text {
value: "Pressure".into(),
}]],
},
},
FragmentItem::Note {
text: "Sampled.".into(),
},
],
};
let json = serde_json::to_string(&fragment).unwrap();
let back: Fragment = serde_json::from_str(&json).unwrap();
assert_eq!(back, fragment);
}
#[test]
fn chart_serde_wire_shape_is_stable() {
let chart = Chart {
x_label: "Minimum pressure".into(),
x_unit: Some("m".into()),
y_label: "Junctions".into(),
y_unit: None,
data: ChartData::Bar {
categories: vec!["0 – 14".into()],
values: vec![3.0],
},
};
assert_eq!(
serde_json::to_string(&FragmentItem::Chart {
chart: chart.clone()
})
.unwrap(),
r#"{"type":"chart","chart":{"xLabel":"Minimum pressure","xUnit":"m","yLabel":"Junctions","data":{"type":"bar","categories":["0 – 14"],"values":[3.0]}}}"#
);
let json = serde_json::to_string(&chart).unwrap();
assert_eq!(serde_json::from_str::<Chart>(&json).unwrap(), chart);
}
#[test]
fn value_kind_mapping() {
assert_eq!(Value::Integer { value: 1 }.kind(), Some(ValueKind::Integer));
assert_eq!(Value::Absent.kind(), None);
}
#[test]
fn block_error_messages_are_descriptive() {
let e = BlockError::Unavailable {
reason: "the run has no water-quality results".into(),
};
assert!(e.to_string().contains("no water-quality results"));
let e = BlockError::UnknownBlock {
id: "wds.nope".into(),
};
assert!(e.to_string().contains("wds.nope"));
}
}