#![allow(dead_code)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Payload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<Status>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(flatten)]
pub body: Body,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "shape", content = "data", rename_all = "snake_case")]
pub enum Body {
Text(TextData),
TextBlock(TextBlockData),
MarkdownTextBlock(MarkdownTextBlockData),
LinkedTextBlock(LinkedTextBlockData),
ImageLinkedList(ImageLinkedListData),
Entries(EntriesData),
Ratio(RatioData),
NumberSeries(NumberSeriesData),
PointSeries(PointSeriesData),
Bars(BarsData),
Image(ImageData),
PixelArt(PixelArtData),
Calendar(CalendarData),
Heatmap(HeatmapData),
Badge(BadgeData),
Timeline(TimelineData),
Error(ErrorData),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ErrorData {
pub kind: ErrorKind,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ErrorKind {
Fetch,
Timeout,
UnknownFetcher,
ShapeMismatch,
RequiresTrust,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Ok,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TextData {
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TextBlockData {
pub lines: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MarkdownTextBlockData {
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LinkedTextBlockData {
pub items: Vec<LinkedLine>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LinkedLine {
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ImageLinkedListData {
pub items: Vec<ImageLinkedItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ImageLinkedItem {
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thumbnail_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subtitle: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EntriesData {
pub items: Vec<Entry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Entry {
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<Status>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RatioData {
pub value: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub denominator: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NumberSeriesData {
pub values: Vec<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PointSeriesData {
pub series: Vec<PointSeries>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PointSeries {
pub name: String,
pub points: Vec<(f64, f64)>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BarsData {
pub bars: Vec<Bar>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Bar {
pub label: String,
pub value: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ImageData {
pub path: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct PixelColor {
pub r: u8,
pub g: u8,
pub b: u8,
#[serde(default = "PixelColor::opaque_alpha")]
pub a: u8,
}
impl PixelColor {
pub const TRANSPARENT: Self = Self {
r: 0,
g: 0,
b: 0,
a: 0,
};
pub const fn opaque(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b, a: 255 }
}
pub fn is_transparent(self) -> bool {
self.a == 0
}
fn opaque_alpha() -> u8 {
255
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PixelArtData {
pub pixels: Vec<Vec<PixelColor>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HeatmapData {
pub cells: Vec<Vec<u32>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thresholds: Option<Vec<u32>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub row_labels: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub col_labels: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BadgeData {
pub status: Status,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TimelineData {
pub events: Vec<TimelineEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TimelineEvent {
pub timestamp: i64,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<Status>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CalendarData {
pub year: i32,
pub month: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub day: Option<u8>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<u8>,
}
#[cfg(test)]
mod tests {
use super::*;
fn round_trip(payload: &Payload) -> Payload {
let json = serde_json::to_string(payload).unwrap();
serde_json::from_str(&json).unwrap()
}
fn bare(body: Body) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body,
}
}
#[test]
fn text_round_trips() {
let p = Payload {
icon: None,
status: None,
format: Some("{branch}".into()),
body: Body::Text(TextData {
value: "main".into(),
}),
};
assert_eq!(p, round_trip(&p));
}
#[test]
fn text_parses_from_spec_example() {
let json = r#"{"shape":"text","data":{"value":"main"}}"#;
let p: Payload = serde_json::from_str(json).unwrap();
assert!(matches!(p.body, Body::Text(_)));
}
#[test]
fn text_serializes_with_expected_shape_tag() {
let p = bare(Body::Text(TextData {
value: "main".into(),
}));
let v: serde_json::Value = serde_json::to_value(&p).unwrap();
assert_eq!(v["shape"], "text");
assert_eq!(v["data"]["value"], "main");
}
#[test]
fn text_block_round_trips() {
let p = bare(Body::TextBlock(TextBlockData {
lines: vec!["feat: a".into(), "fix: b".into()],
}));
assert_eq!(p, round_trip(&p));
}
#[test]
fn text_block_serializes_with_expected_shape_tag() {
let p = bare(Body::TextBlock(TextBlockData {
lines: vec!["a".into()],
}));
let v: serde_json::Value = serde_json::to_value(&p).unwrap();
assert_eq!(v["shape"], "text_block");
assert_eq!(v["data"]["lines"][0], "a");
}
#[test]
fn markdown_text_block_round_trips() {
let p = bare(Body::MarkdownTextBlock(MarkdownTextBlockData {
value: "# heading\n\n- a\n- b".into(),
}));
assert_eq!(p, round_trip(&p));
}
#[test]
fn markdown_text_block_serializes_with_expected_shape_tag() {
let p = bare(Body::MarkdownTextBlock(MarkdownTextBlockData {
value: "# x".into(),
}));
let v: serde_json::Value = serde_json::to_value(&p).unwrap();
assert_eq!(v["shape"], "markdown_text_block");
assert_eq!(v["data"]["value"], "# x");
}
#[test]
fn linked_text_block_round_trips() {
let p = bare(Body::LinkedTextBlock(LinkedTextBlockData {
items: vec![
LinkedLine {
text: "234pt 56c Show HN: x".into(),
url: Some("https://example.com/x".into()),
},
LinkedLine {
text: "no link here".into(),
url: None,
},
],
}));
assert_eq!(p, round_trip(&p));
}
#[test]
fn linked_text_block_serializes_with_expected_shape_tag() {
let p = bare(Body::LinkedTextBlock(LinkedTextBlockData {
items: vec![LinkedLine {
text: "hello".into(),
url: Some("https://example.com".into()),
}],
}));
let v: serde_json::Value = serde_json::to_value(&p).unwrap();
assert_eq!(v["shape"], "linked_text_block");
assert_eq!(v["data"]["items"][0]["text"], "hello");
assert_eq!(v["data"]["items"][0]["url"], "https://example.com");
}
#[test]
fn linked_text_block_omits_url_when_none() {
let p = bare(Body::LinkedTextBlock(LinkedTextBlockData {
items: vec![LinkedLine {
text: "hello".into(),
url: None,
}],
}));
let json = serde_json::to_string(&p).unwrap();
assert!(!json.contains("\"url\""), "json: {json:?}");
}
#[test]
fn image_linked_list_round_trips() {
let p = bare(Body::ImageLinkedList(ImageLinkedListData {
items: vec![
ImageLinkedItem {
title: "Rust 1.99 released".into(),
url: Some("https://example.com/rust".into()),
thumbnail_path: Some("/tmp/rust.png".into()),
subtitle: Some("rust-lang.org · 2h ago".into()),
},
ImageLinkedItem {
title: "no thumbnail here".into(),
url: None,
thumbnail_path: None,
subtitle: None,
},
],
}));
assert_eq!(p, round_trip(&p));
}
#[test]
fn image_linked_list_serializes_with_expected_shape_tag() {
let p = bare(Body::ImageLinkedList(ImageLinkedListData {
items: vec![ImageLinkedItem {
title: "hello".into(),
url: Some("https://example.com".into()),
thumbnail_path: Some("/tmp/x.png".into()),
subtitle: None,
}],
}));
let v: serde_json::Value = serde_json::to_value(&p).unwrap();
assert_eq!(v["shape"], "image_linked_list");
assert_eq!(v["data"]["items"][0]["title"], "hello");
assert_eq!(v["data"]["items"][0]["url"], "https://example.com");
assert_eq!(v["data"]["items"][0]["thumbnail_path"], "/tmp/x.png");
assert!(v["data"]["items"][0].get("subtitle").is_none());
}
#[test]
fn image_linked_list_omits_optional_fields_when_none() {
let p = bare(Body::ImageLinkedList(ImageLinkedListData {
items: vec![ImageLinkedItem {
title: "only title".into(),
url: None,
thumbnail_path: None,
subtitle: None,
}],
}));
let json = serde_json::to_string(&p).unwrap();
assert!(!json.contains("\"url\""), "json: {json:?}");
assert!(!json.contains("\"thumbnail_path\""), "json: {json:?}");
assert!(!json.contains("\"subtitle\""), "json: {json:?}");
}
#[test]
fn entries_round_trips() {
let p = bare(Body::Entries(EntriesData {
items: vec![Entry {
key: "uptime".into(),
value: Some("3d".into()),
status: Some(Status::Ok),
}],
}));
assert_eq!(p, round_trip(&p));
}
#[test]
fn ratio_round_trips() {
let p = bare(Body::Ratio(RatioData {
value: 0.73,
label: Some("CPU".into()),
denominator: Some(100),
}));
assert_eq!(p, round_trip(&p));
}
#[test]
fn number_series_round_trips() {
let p = bare(Body::NumberSeries(NumberSeriesData {
values: vec![1, 2, 3],
}));
assert_eq!(p, round_trip(&p));
}
#[test]
fn point_series_round_trips() {
let p = bare(Body::PointSeries(PointSeriesData {
series: vec![PointSeries {
name: "temp".into(),
points: vec![(0.0, 20.0), (1.0, 21.5)],
}],
}));
assert_eq!(p, round_trip(&p));
}
#[test]
fn bars_round_trips() {
let p = bare(Body::Bars(BarsData {
bars: vec![
Bar {
label: "a".into(),
value: 3,
value_label: None,
},
Bar {
label: "b".into(),
value: 5,
value_label: None,
},
],
}));
assert_eq!(p, round_trip(&p));
}
#[test]
fn shape_tag_uses_snake_case() {
let p = bare(Body::Bars(BarsData { bars: vec![] }));
let v: serde_json::Value = serde_json::to_value(&p).unwrap();
assert_eq!(v["shape"], "bars");
}
#[test]
fn single_line_via_text_variant() {
let p = bare(Body::Text(TextData {
value: "12:34".into(),
}));
assert_eq!(p, round_trip(&p));
}
#[test]
fn badge_round_trips() {
let p = bare(Body::Badge(BadgeData {
status: Status::Warn,
label: "deploy degraded".into(),
}));
assert_eq!(p, round_trip(&p));
}
#[test]
fn badge_serializes_with_expected_shape_tag() {
let p = bare(Body::Badge(BadgeData {
status: Status::Error,
label: "oncall paging".into(),
}));
let v: serde_json::Value = serde_json::to_value(&p).unwrap();
assert_eq!(v["shape"], "badge");
assert_eq!(v["data"]["status"], "error");
assert_eq!(v["data"]["label"], "oncall paging");
}
#[test]
fn timeline_round_trips() {
let p = bare(Body::Timeline(TimelineData {
events: vec![
TimelineEvent {
timestamp: 1_700_000_000,
title: "merged #42".into(),
detail: Some("feat(render): heatmap".into()),
status: Some(Status::Ok),
},
TimelineEvent {
timestamp: 1_699_990_000,
title: "opened #41".into(),
detail: None,
status: None,
},
],
}));
assert_eq!(p, round_trip(&p));
}
#[test]
fn timeline_serializes_with_expected_shape_tag() {
let p = bare(Body::Timeline(TimelineData {
events: vec![TimelineEvent {
timestamp: 1_700_000_000,
title: "x".into(),
detail: None,
status: None,
}],
}));
let v: serde_json::Value = serde_json::to_value(&p).unwrap();
assert_eq!(v["shape"], "timeline");
assert_eq!(v["data"]["events"][0]["timestamp"], 1_700_000_000_i64);
assert_eq!(v["data"]["events"][0]["title"], "x");
assert!(v["data"]["events"][0].get("detail").is_none());
assert!(v["data"]["events"][0].get("status").is_none());
}
#[test]
fn image_round_trips() {
let p = bare(Body::Image(ImageData {
path: "/tmp/a.png".into(),
}));
assert_eq!(p, round_trip(&p));
}
#[test]
fn optional_fields_absent_in_serialization() {
let p = bare(Body::TextBlock(TextBlockData { lines: vec![] }));
let json = serde_json::to_string(&p).unwrap();
assert!(!json.contains("icon"));
assert!(!json.contains("status"));
}
#[test]
fn error_round_trips_with_full_field_set() {
let p = bare(Body::Error(ErrorData {
kind: ErrorKind::Fetch,
message: "⚠ deariary 429".into(),
detail: Some("see logs".into()),
}));
assert_eq!(p, round_trip(&p));
}
#[test]
fn error_round_trips_without_detail() {
let p = bare(Body::Error(ErrorData {
kind: ErrorKind::Timeout,
message: "⏱ timed out".into(),
detail: None,
}));
assert_eq!(p, round_trip(&p));
let json = serde_json::to_string(&p).unwrap();
assert!(
!json.contains("detail"),
"skip_serializing_if must drop None detail: {json}"
);
}
#[test]
fn error_serializes_with_expected_shape_tag_and_snake_case_kind() {
let p = bare(Body::Error(ErrorData {
kind: ErrorKind::ShapeMismatch,
message: "⚠ static_text can't emit bars".into(),
detail: Some("check `render` in config".into()),
}));
let v: serde_json::Value = serde_json::to_value(&p).unwrap();
assert_eq!(v["shape"], "error");
assert_eq!(v["data"]["kind"], "shape_mismatch");
assert_eq!(v["data"]["message"], "⚠ static_text can't emit bars");
assert_eq!(v["data"]["detail"], "check `render` in config");
}
#[test]
fn error_kind_covers_every_known_variant() {
for (kind, expected) in [
(ErrorKind::Fetch, "fetch"),
(ErrorKind::Timeout, "timeout"),
(ErrorKind::UnknownFetcher, "unknown_fetcher"),
(ErrorKind::ShapeMismatch, "shape_mismatch"),
(ErrorKind::RequiresTrust, "requires_trust"),
] {
let s = serde_json::to_string(&kind).unwrap();
assert_eq!(s, format!("\"{expected}\""), "kind: {kind:?}");
let back: ErrorKind = serde_json::from_str(&s).unwrap();
assert_eq!(back, kind);
}
}
}