use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use serde::Serialize;
use crate::error::ApiError;
use crate::page::{Card, Section};
use crate::schema::Schema;
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct Overview {
#[serde(skip_serializing_if = "Vec::is_empty")]
groups: Vec<RowGroup>,
#[serde(skip_serializing_if = "Option::is_none")]
footer: Option<Footer>,
#[serde(skip_serializing_if = "Vec::is_empty")]
cards: Vec<Card>,
#[serde(skip_serializing_if = "Vec::is_empty")]
sections: Vec<Section>,
}
impl Overview {
pub fn new() -> Self {
Self::default()
}
pub fn group(mut self, group: RowGroup) -> Self {
self.groups.push(group);
self
}
pub fn groups(mut self, groups: impl IntoIterator<Item = RowGroup>) -> Self {
self.groups.extend(groups);
self
}
pub fn footer(mut self, footer: Footer) -> Self {
self.footer = Some(footer);
self
}
pub fn card(mut self, card: Card) -> Self {
self.cards.push(card);
self
}
pub fn cards(mut self, cards: impl IntoIterator<Item = Card>) -> Self {
self.cards.extend(cards);
self
}
pub fn section(mut self, section: Section) -> Self {
self.sections.push(section);
self
}
pub(crate) fn is_empty(&self) -> bool {
self.groups.is_empty()
&& self.footer.is_none()
&& self.cards.is_empty()
&& self.sections.is_empty()
}
pub(crate) fn check_keys(&self, table: &str) -> Result<(), ApiError> {
let mut seen = BTreeSet::new();
for group in &self.groups {
if !seen.insert(group.key.as_str()) {
return Err(ApiError::server(format!(
"table \"{table}\" has two groups with the key \"{}\"",
group.key
)));
}
}
Ok(())
}
pub(crate) fn check_fields(&self, table: &str, schema: &Schema) -> Result<(), ApiError> {
for group in &self.groups {
if let Some(field) = group.values.keys().find(|f| !schema.has_column(f)) {
return Err(ApiError::server(format!(
"table \"{table}\" has a value under the field \"{field}\" in the heading of the group \"{}\", which is none of the table's columns",
group.key
)));
}
}
if let Some(footer) = &self.footer
&& let Some(field) = footer.values.keys().find(|f| !schema.has_column(f))
{
return Err(ApiError::server(format!(
"table \"{table}\" has a value under the field \"{field}\" in its footer, which is none of the table's columns"
)));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RowGroup {
key: String,
title: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
facts: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
values: BTreeMap<String, serde_json::Value>,
}
impl RowGroup {
pub fn new(key: impl Into<String>, title: impl Into<String>) -> Self {
Self {
key: key.into(),
title: title.into(),
facts: Vec::new(),
note: None,
values: BTreeMap::new(),
}
}
pub fn fact(mut self, fact: impl fmt::Display) -> Self {
self.facts.push(fact.to_string());
self
}
pub fn note(mut self, note: impl Into<String>) -> Self {
self.note = Some(note.into());
self
}
pub fn value(mut self, field: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
self.values.insert(field.into(), value.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Footer {
title: String,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
values: BTreeMap<String, serde_json::Value>,
}
impl Footer {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
values: BTreeMap::new(),
}
}
pub fn value(mut self, field: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
self.values.insert(field.into(), value.into());
self
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::schema::{Column, Format};
#[test]
fn an_overview_serializes_to_the_documented_shape() {
let overview = Overview::new()
.group(
RowGroup::new("cen", "Central Lending Library")
.fact("cen")
.value("cost", 1234.5),
)
.groups([
RowGroup::new("hbr", "Harbour Branch")
.fact("hbr")
.fact("closed")
.value("cost", 0),
RowGroup::new("bin", "Bindery")
.note("on order")
.value("cost", 86.25),
])
.footer(Footer::new("All branches").value("cost", 1320.75));
assert_eq!(
serde_json::to_value(overview).unwrap(),
json!({
"groups": [
{ "key": "cen", "title": "Central Lending Library",
"facts": ["cen"], "values": { "cost": 1234.5 } },
{ "key": "hbr", "title": "Harbour Branch",
"facts": ["hbr", "closed"], "values": { "cost": 0 } },
{ "key": "bin", "title": "Bindery", "note": "on order",
"values": { "cost": 86.25 } } ],
"footer": { "title": "All branches", "values": { "cost": 1320.75 } } })
);
}
#[test]
fn the_parts_an_overview_was_not_given_are_omitted() {
assert!(Overview::new().is_empty());
assert_eq!(serde_json::to_value(Overview::new()).unwrap(), json!({}));
let bare = Overview::new().group(RowGroup::new("cen", "Central"));
assert!(!bare.is_empty());
assert_eq!(
serde_json::to_value(bare).unwrap(),
json!({ "groups": [{ "key": "cen", "title": "Central" }] })
);
let footed = Overview::new().footer(Footer::new("All branches"));
assert!(!footed.is_empty());
assert_eq!(
serde_json::to_value(footed).unwrap(),
json!({ "footer": { "title": "All branches" } })
);
}
#[test]
fn a_summarys_cards_and_sections_serialize_in_the_order_given() {
let spent = Card::new("Spent").figure(1320.75, Format::money().unit("CAD"));
let on_order = Card::new("On order").figure(86.25, Format::money().unit("CAD"));
let by_branch = Section::new([
Column::string("branch", "Branch"),
Column::number("share", "Share").format(Format::percent(1)),
])
.rows([json!({ "branch": "Central", "share": 0.75 })])
.unwrap();
let overview = Overview::new()
.card(spent)
.cards([on_order, Card::new("Unbudgeted")])
.section(by_branch)
.section(Section::new([Column::string("month", "Month")]).heading("By month"))
.footer(Footer::new("All branches").value("cost", 1320.75));
assert_eq!(
serde_json::to_value(overview).unwrap(),
json!({
"footer": { "title": "All branches", "values": { "cost": 1320.75 } },
"cards": [
{ "title": "Spent",
"figure": { "value": 1320.75,
"format": { "decimals": 2, "grouped": true, "unit": "CAD" } } },
{ "title": "On order",
"figure": { "value": 86.25,
"format": { "decimals": 2, "grouped": true, "unit": "CAD" } } },
{ "title": "Unbudgeted" } ],
"sections": [
{ "columns": [
{ "field": "branch", "label": "Branch", "type": "string" },
{ "field": "share", "label": "Share", "type": "number",
"format": { "decimals": 1, "percent": true } } ],
"rows": [ { "branch": "Central", "share": 0.75 } ] },
{ "heading": "By month",
"columns": [ { "field": "month", "label": "Month", "type": "string" } ],
"rows": [] } ] })
);
}
#[test]
fn a_summary_alone_is_something_to_say() {
let carded = Overview::new().card(Card::new("Spent"));
assert!(!carded.is_empty());
assert_eq!(
serde_json::to_value(carded).unwrap(),
json!({ "cards": [{ "title": "Spent" }] })
);
let sectioned = Overview::new().section(Section::new([Column::string("branch", "Branch")]));
assert!(!sectioned.is_empty());
assert_eq!(
serde_json::to_value(sectioned).unwrap(),
json!({ "sections": [{ "columns": [
{ "field": "branch", "label": "Branch", "type": "string" } ], "rows": [] }] })
);
}
#[test]
fn a_figure_that_is_not_finite_is_sent_as_null() {
let group = RowGroup::new("cen", "Central").value("cost", f64::NAN);
assert_eq!(
serde_json::to_value(group).unwrap()["values"]["cost"],
json!(null)
);
}
#[test]
fn two_groups_with_one_key_are_refused_naming_the_table_and_the_key() {
let twice = Overview::new()
.group(RowGroup::new("cen", "Central"))
.group(RowGroup::new("est", "Eastside"))
.group(RowGroup::new("cen", "Central again"));
let refused = twice.check_keys("purchases").unwrap_err();
assert_eq!(refused.status, 500);
for named in ["\"purchases\"", "\"cen\""] {
assert!(refused.message.contains(named), "{}", refused.message);
}
let once = Overview::new()
.group(RowGroup::new("cen", "Central"))
.group(RowGroup::new("est", "Eastside"));
assert!(once.check_keys("purchases").is_ok());
}
#[test]
fn a_value_under_a_field_no_column_has_is_refused_naming_the_table_and_the_field() {
let schema = Schema::new([
Column::string("item", "Item"),
Column::number("cost", "Cost"),
]);
let in_a_heading =
Overview::new().group(RowGroup::new("cen", "Central").value("costs", 12.5));
let refused = in_a_heading.check_fields("purchases", &schema).unwrap_err();
assert_eq!(refused.status, 500);
for named in ["\"purchases\"", "\"costs\"", "\"cen\""] {
assert!(refused.message.contains(named), "{}", refused.message);
}
let in_the_footer = Overview::new().footer(Footer::new("All").value("total", 12.5));
let refused = in_the_footer
.check_fields("purchases", &schema)
.unwrap_err();
for named in ["\"purchases\"", "\"total\"", "footer"] {
assert!(refused.message.contains(named), "{}", refused.message);
}
let fine = Overview::new()
.group(
RowGroup::new("cen", "Central")
.value("cost", 12.5)
.value("item", 1),
)
.footer(Footer::new("All").value("cost", 12.5));
assert!(fine.check_fields("purchases", &schema).is_ok());
}
}