mod data;
mod style;
pub use data::*;
pub use style::*;
use serde::{Deserialize, Serialize};
#[cfg(feature = "schema")]
use schemars::JsonSchema;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct WidgetWindowConfig {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
pub width: f64,
pub height: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub y: Option<f64>,
#[serde(default)]
pub always_on_top: bool,
#[serde(default = "default_true")]
pub skip_taskbar: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub widget_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<String>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct WidgetConfig {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub small: Option<WidgetElement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub medium: Option<WidgetElement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub large: Option<WidgetElement>,
}
impl Default for WidgetConfig {
fn default() -> Self {
Self {
version: 1,
small: None,
medium: None,
large: None,
}
}
}
impl WidgetConfig {
pub fn small(el: impl Into<WidgetElement>) -> Self {
Self {
small: Some(el.into()),
..Default::default()
}
}
pub fn with_medium(mut self, el: impl Into<WidgetElement>) -> Self {
self.medium = Some(el.into());
self
}
pub fn with_large(mut self, el: impl Into<WidgetElement>) -> Self {
self.large = Some(el.into());
self
}
}
fn default_version() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum WidgetElement {
#[serde(rename = "vstack")]
VStack(VStackElement),
#[serde(rename = "hstack")]
HStack(HStackElement),
#[serde(rename = "zstack")]
ZStack(ZStackElement),
#[serde(rename = "grid")]
Grid(GridElement),
#[serde(rename = "container")]
Container(ContainerElement),
#[serde(rename = "text")]
Text(TextElement),
#[serde(rename = "image")]
Image(ImageElement),
#[serde(rename = "progress")]
Progress(ProgressElement),
#[serde(rename = "gauge")]
Gauge(GaugeElement),
#[serde(rename = "button")]
Button(ButtonElement),
#[serde(rename = "toggle")]
Toggle(ToggleElement),
#[serde(rename = "divider")]
Divider(DividerElement),
#[serde(rename = "spacer")]
Spacer(SpacerElement),
#[serde(rename = "date")]
Date(DateElement),
#[serde(rename = "chart")]
Chart(ChartElement),
#[serde(rename = "list")]
List(ListElement),
#[serde(rename = "link")]
Link(LinkElement),
#[serde(rename = "shape")]
Shape(ShapeElement),
#[serde(rename = "timer")]
Timer(TimerElement),
#[serde(rename = "canvas")]
Canvas(CanvasElement),
#[serde(rename = "label")]
Label(LabelElement),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct VStackElement {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<WidgetElement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spacing: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alignment: Option<HorizontalAlignment>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct HStackElement {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<WidgetElement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spacing: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alignment: Option<VerticalAlignment>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct ZStackElement {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<WidgetElement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alignment: Option<String>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct GridElement {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<WidgetElement>,
#[serde(default = "default_columns")]
pub columns: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spacing: Option<f64>,
#[serde(
rename = "rowSpacing",
default,
skip_serializing_if = "Option::is_none"
)]
pub row_spacing: Option<f64>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct ContainerElement {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<WidgetElement>,
#[serde(
rename = "contentAlignment",
default,
skip_serializing_if = "Option::is_none"
)]
pub content_alignment: Option<String>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct TextElement {
pub content: String,
#[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<f64>,
#[serde(
rename = "fontWeight",
default,
skip_serializing_if = "Option::is_none"
)]
pub font_weight: Option<FontWeight>,
#[serde(
rename = "fontDesign",
default,
skip_serializing_if = "Option::is_none"
)]
pub font_design: Option<FontDesign>,
#[serde(rename = "textStyle", default, skip_serializing_if = "Option::is_none")]
pub text_style: Option<TextStyle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<ColorValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alignment: Option<TextAlignment>,
#[serde(rename = "lineLimit", default, skip_serializing_if = "Option::is_none")]
pub line_limit: Option<u32>,
#[serde(flatten)]
pub style: ElementStyle,
}
impl TextElement {
pub fn font_size(mut self, size: f64) -> Self {
self.font_size = Some(size);
self
}
pub fn font_weight(mut self, weight: FontWeight) -> Self {
self.font_weight = Some(weight);
self
}
pub fn color(mut self, color: impl Into<ColorValue>) -> Self {
self.color = Some(color.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct ImageElement {
#[serde(
rename = "systemName",
default,
skip_serializing_if = "Option::is_none"
)]
pub system_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<ColorValue>,
#[serde(
rename = "contentMode",
default,
skip_serializing_if = "Option::is_none"
)]
pub content_mode: Option<ContentMode>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct ProgressElement {
pub value: f64,
#[serde(default = "default_total")]
pub total: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tint: Option<ColorValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<ColorValue>,
#[serde(
rename = "barStyle",
default,
skip_serializing_if = "Option::is_none"
)]
pub bar_style: Option<ProgressStyle>,
#[serde(flatten)]
pub style: ElementStyle,
}
impl Default for ProgressElement {
fn default() -> Self {
Self {
value: 0.0,
total: 1.0,
label: None,
tint: None,
color: None,
bar_style: None,
style: ElementStyle::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct GaugeElement {
pub value: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(
rename = "currentValueLabel",
default,
skip_serializing_if = "Option::is_none"
)]
pub current_value_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tint: Option<ColorValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<ColorValue>,
#[serde(
rename = "gaugeStyle",
default,
skip_serializing_if = "Option::is_none"
)]
pub gauge_style: Option<GaugeStyle>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct ButtonElement {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<ColorValue>,
#[serde(
rename = "backgroundColor",
default,
skip_serializing_if = "Option::is_none"
)]
pub background_color: Option<ColorValue>,
#[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<f64>,
#[serde(
rename = "textAlignment",
default,
skip_serializing_if = "Option::is_none"
)]
pub text_alignment: Option<TextAlignment>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct ToggleElement {
#[serde(rename = "isOn")]
pub is_on: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tint: Option<ColorValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct DividerElement {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<ColorValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thickness: Option<f64>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct SpacerElement {
#[serde(rename = "minLength", default, skip_serializing_if = "Option::is_none")]
pub min_length: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct DateElement {
pub date: String,
#[serde(rename = "dateStyle", default, skip_serializing_if = "Option::is_none")]
pub date_style: Option<DateStyle>,
#[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<ColorValue>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct ChartElement {
#[serde(rename = "chartType")]
pub chart_type: ChartType,
#[serde(default, rename = "chartData", skip_serializing_if = "Vec::is_empty")]
pub chart_data: Vec<ChartDataPoint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tint: Option<ColorValue>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct ListElement {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub items: Vec<ListItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spacing: Option<f64>,
#[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<ColorValue>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct LinkElement {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<WidgetElement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct ShapeElement {
#[serde(rename = "shapeType")]
pub shape_type: ShapeType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fill: Option<ColorValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke: Option<ColorValue>,
#[serde(
rename = "strokeWidth",
default,
skip_serializing_if = "Option::is_none"
)]
pub stroke_width: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<f64>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct TimerElement {
#[serde(rename = "targetDate")]
pub target_date: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub counting: Option<TimerCounting>,
#[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<f64>,
#[serde(
rename = "fontWeight",
default,
skip_serializing_if = "Option::is_none"
)]
pub font_weight: Option<FontWeight>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<ColorValue>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct CanvasElement {
pub width: f64,
pub height: f64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub elements: Vec<CanvasDrawCommand>,
#[serde(flatten)]
pub style: ElementStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct LabelElement {
pub text: String,
#[serde(rename = "systemName")]
pub system_name: String,
#[serde(rename = "iconColor", default, skip_serializing_if = "Option::is_none")]
pub icon_color: Option<ColorValue>,
#[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<f64>,
#[serde(
rename = "fontWeight",
default,
skip_serializing_if = "Option::is_none"
)]
pub font_weight: Option<FontWeight>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<ColorValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spacing: Option<f64>,
#[serde(flatten)]
pub style: ElementStyle,
}
impl From<VStackElement> for WidgetElement {
fn from(value: VStackElement) -> Self {
WidgetElement::VStack(value)
}
}
impl From<HStackElement> for WidgetElement {
fn from(value: HStackElement) -> Self {
WidgetElement::HStack(value)
}
}
impl From<ZStackElement> for WidgetElement {
fn from(value: ZStackElement) -> Self {
WidgetElement::ZStack(value)
}
}
impl From<GridElement> for WidgetElement {
fn from(value: GridElement) -> Self {
WidgetElement::Grid(value)
}
}
impl From<ContainerElement> for WidgetElement {
fn from(value: ContainerElement) -> Self {
WidgetElement::Container(value)
}
}
impl From<TextElement> for WidgetElement {
fn from(value: TextElement) -> Self {
WidgetElement::Text(value)
}
}
impl From<ImageElement> for WidgetElement {
fn from(value: ImageElement) -> Self {
WidgetElement::Image(value)
}
}
impl From<ProgressElement> for WidgetElement {
fn from(value: ProgressElement) -> Self {
WidgetElement::Progress(value)
}
}
impl From<GaugeElement> for WidgetElement {
fn from(value: GaugeElement) -> Self {
WidgetElement::Gauge(value)
}
}
impl From<ButtonElement> for WidgetElement {
fn from(value: ButtonElement) -> Self {
WidgetElement::Button(value)
}
}
impl From<ToggleElement> for WidgetElement {
fn from(value: ToggleElement) -> Self {
WidgetElement::Toggle(value)
}
}
impl From<DividerElement> for WidgetElement {
fn from(value: DividerElement) -> Self {
WidgetElement::Divider(value)
}
}
impl From<SpacerElement> for WidgetElement {
fn from(value: SpacerElement) -> Self {
WidgetElement::Spacer(value)
}
}
impl From<DateElement> for WidgetElement {
fn from(value: DateElement) -> Self {
WidgetElement::Date(value)
}
}
impl From<ChartElement> for WidgetElement {
fn from(value: ChartElement) -> Self {
WidgetElement::Chart(value)
}
}
impl From<ListElement> for WidgetElement {
fn from(value: ListElement) -> Self {
WidgetElement::List(value)
}
}
impl From<LinkElement> for WidgetElement {
fn from(value: LinkElement) -> Self {
WidgetElement::Link(value)
}
}
impl From<ShapeElement> for WidgetElement {
fn from(value: ShapeElement) -> Self {
WidgetElement::Shape(value)
}
}
impl From<TimerElement> for WidgetElement {
fn from(value: TimerElement) -> Self {
WidgetElement::Timer(value)
}
}
impl From<CanvasElement> for WidgetElement {
fn from(value: CanvasElement) -> Self {
WidgetElement::Canvas(value)
}
}
impl From<LabelElement> for WidgetElement {
fn from(value: LabelElement) -> Self {
WidgetElement::Label(value)
}
}
pub fn text(content: impl Into<String>) -> TextElement {
TextElement {
content: content.into(),
..Default::default()
}
}
pub fn vstack(children: Vec<WidgetElement>) -> VStackElement {
VStackElement {
children,
..Default::default()
}
}
pub fn hstack(children: Vec<WidgetElement>) -> HStackElement {
HStackElement {
children,
..Default::default()
}
}
fn default_columns() -> u32 {
2
}
fn default_total() -> f64 {
1.0
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn option_none_omitted_from_json() {
let cfg = WidgetConfig::small(TextElement {
content: "hi".into(),
font_size: Some(12.0),
..Default::default()
});
let json = serde_json::to_string(&cfg).unwrap();
assert!(!json.contains("null"), "JSON must not contain null: {json}");
assert!(!json.contains("\"medium\""));
assert!(!json.contains("\"fontWeight\""));
let back: WidgetConfig = serde_json::from_str(&json).unwrap();
assert!(back.medium.is_none());
match back.small.unwrap() {
WidgetElement::Text(TextElement {
font_weight,
content,
..
}) => {
assert!(font_weight.is_none());
assert_eq!(content, "hi");
}
other => panic!("expected text, got {other:?}"),
}
}
#[test]
fn ergonomic_builders_serialize() {
let cfg = WidgetConfig::small(vstack(vec![text("72°")
.font_size(36.0)
.font_weight(FontWeight::Bold)
.color("#fff")
.into()]));
let v = serde_json::to_value(&cfg).unwrap();
assert_eq!(v["small"]["type"], "vstack");
assert_eq!(v["small"]["children"][0]["type"], "text");
assert_eq!(v["small"]["children"][0]["content"], "72°");
assert_eq!(v["small"]["children"][0]["fontSize"], 36.0);
assert_eq!(v["small"]["children"][0]["fontWeight"], "bold");
assert_eq!(v["small"]["children"][0]["color"], "#fff");
}
fn walk_json_files(dir: &std::path::Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk_json_files(&path, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("json") {
out.push(path);
}
}
}
#[test]
fn wire_format_unchanged() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
let mut files = Vec::new();
walk_json_files(&root, &mut files);
assert!(
!files.is_empty(),
"expected fixtures under {}",
root.display()
);
for path in files {
let raw = std::fs::read_to_string(&path).unwrap();
let cfg: WidgetConfig = serde_json::from_str(&raw).unwrap_or_else(|e| {
panic!("deserialize {}: {e}", path.display());
});
let encoded = serde_json::to_value(&cfg).unwrap();
let again: WidgetConfig = serde_json::from_value(encoded.clone()).unwrap();
assert_eq!(
encoded,
serde_json::to_value(&again).unwrap(),
"wire drift in {}",
path.display()
);
let out = serde_json::to_string(&cfg).unwrap();
assert!(
!out.contains("null"),
"{} serialized with null: {out}",
path.display()
);
}
}
}