use std::fmt;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{ObjectId, QuantityDimension, RuleId, Scope, SourceId};
#[derive(Debug, Error, PartialEq)]
pub enum ReportTableError {
#[error(
"invalid {kind} `{name}`: use 1 to 64 lowercase ASCII letters, digits, `-` or `_`, starting with a letter or digit"
)]
InvalidName { kind: &'static str, name: String },
#[error("table `{0}` has no columns")]
NoColumns(String),
#[error("table `{table}` declares column `{column}` twice")]
DuplicateColumn { table: String, column: String },
#[error("table `{table}` row {row}: {detail}")]
InvalidRow {
table: String,
row: String,
detail: String,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReportColumnKind {
Quantity { dimension: QuantityDimension },
Number,
Text,
}
impl ReportColumnKind {
#[must_use]
pub fn unit_symbol(self) -> Option<String> {
match self {
Self::Quantity { dimension } => Some(dimension.unit_symbol()),
Self::Number | Self::Text => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "ColumnWire", into = "ColumnWire")]
pub struct ReportColumn {
pub id: String,
pub kind: ReportColumnKind,
}
#[derive(Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum KindTag {
Quantity,
Number,
Text,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ColumnWire {
id: String,
kind: KindTag,
#[serde(default, skip_serializing_if = "Option::is_none")]
dimension: Option<QuantityDimension>,
}
impl From<ReportColumn> for ColumnWire {
fn from(column: ReportColumn) -> Self {
let (kind, dimension) = match column.kind {
ReportColumnKind::Quantity { dimension } => (KindTag::Quantity, Some(dimension)),
ReportColumnKind::Number => (KindTag::Number, None),
ReportColumnKind::Text => (KindTag::Text, None),
};
Self {
id: column.id,
kind,
dimension,
}
}
}
impl TryFrom<ColumnWire> for ReportColumn {
type Error = String;
fn try_from(wire: ColumnWire) -> Result<Self, String> {
let kind = match (wire.kind, wire.dimension) {
(KindTag::Quantity, Some(dimension)) => ReportColumnKind::Quantity { dimension },
(KindTag::Number, None) => ReportColumnKind::Number,
(KindTag::Text, None) => ReportColumnKind::Text,
(KindTag::Quantity, None) => {
return Err(format!("quantity column `{}` has no dimension", wire.id));
}
(_, Some(_)) => {
return Err(format!(
"column `{}` states a dimension but is not a quantity",
wire.id
));
}
};
Ok(Self { id: wire.id, kind })
}
}
impl ReportColumn {
#[must_use]
pub fn quantity(id: impl Into<String>, dimension: QuantityDimension) -> Self {
Self {
id: id.into(),
kind: ReportColumnKind::Quantity { dimension },
}
}
#[must_use]
pub fn number(id: impl Into<String>) -> Self {
Self {
id: id.into(),
kind: ReportColumnKind::Number,
}
}
#[must_use]
pub fn text(id: impl Into<String>) -> Self {
Self {
id: id.into(),
kind: ReportColumnKind::Text,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum ReportValue {
Unknown,
Exact { value: f64 },
Interval { lower: f64, upper: f64 },
Text { value: String },
}
impl ReportValue {
#[must_use]
pub fn exact(value: f64) -> Self {
Self::measured(value, value)
}
#[must_use]
pub fn measured(lower: f64, upper: f64) -> Self {
if !lower.is_finite() || !upper.is_finite() || lower > upper {
Self::Unknown
} else if lower < upper {
Self::Interval { lower, upper }
} else {
Self::Exact { value: lower }
}
}
#[must_use]
pub fn text(value: impl Into<String>) -> Self {
Self::Text {
value: value.into(),
}
}
fn misfit(&self, kind: ReportColumnKind) -> Option<&'static str> {
let text = kind == ReportColumnKind::Text;
match self {
Self::Unknown => None,
Self::Text { .. } => (!text).then_some("text in a numeric column"),
Self::Exact { .. } | Self::Interval { .. } if text => Some("a number in a text column"),
Self::Exact { value } => (!value.is_finite()).then_some("the value is not finite"),
Self::Interval { lower, upper } => {
if !lower.is_finite() || !upper.is_finite() {
Some("an interval bound is not finite")
} else if lower >= upper {
Some(
"an interval's lower bound must lie below its upper bound; a point is exact",
)
} else {
None
}
}
}
}
}
impl fmt::Display for ReportValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unknown => f.write_str("unknown"),
Self::Exact { value } => write!(f, "{value}"),
Self::Interval { lower, upper } => write!(f, "{lower}..{upper}"),
Self::Text { value } => f.write_str(value),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ReportRow {
scope: Scope,
group: Vec<String>,
values: Vec<ReportValue>,
}
impl ReportRow {
#[must_use]
pub fn scope(&self) -> &Scope {
&self.scope
}
#[must_use]
pub fn group(&self) -> &[String] {
&self.group
}
fn key(&self) -> (&Scope, &[String]) {
(&self.scope, &self.group)
}
#[must_use]
pub fn values(&self) -> &[ReportValue] {
&self.values
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "TableWire", into = "TableWire")]
pub struct ReportTable {
rule_id: RuleId,
name: String,
group_by: Vec<String>,
columns: Vec<ReportColumn>,
rows: Vec<ReportRow>,
}
impl ReportTable {
pub fn new(
rule_id: RuleId,
name: impl Into<String>,
columns: Vec<ReportColumn>,
) -> Result<Self, ReportTableError> {
Self::grouped(rule_id, name, Vec::new(), columns)
}
pub fn grouped(
rule_id: RuleId,
name: impl Into<String>,
group_by: Vec<String>,
columns: Vec<ReportColumn>,
) -> Result<Self, ReportTableError> {
let name = token(name.into(), "table name")?;
if columns.is_empty() {
return Err(ReportTableError::NoColumns(name));
}
let mut columns_seen = std::collections::BTreeSet::new();
for id in group_by
.iter()
.chain(columns.iter().map(|column| &column.id))
{
token(id.clone(), "column id")?;
if !columns_seen.insert(id.as_str()) {
return Err(ReportTableError::DuplicateColumn {
table: name,
column: id.clone(),
});
}
}
Ok(Self {
rule_id,
name,
group_by,
columns,
rows: Vec::new(),
})
}
pub fn push_row(
&mut self,
scope: impl Into<Scope>,
values: Vec<ReportValue>,
) -> Result<(), ReportTableError> {
self.push_group_row(scope, Vec::new(), values)
}
pub fn push_group_row(
&mut self,
scope: impl Into<Scope>,
group: Vec<String>,
values: Vec<ReportValue>,
) -> Result<(), ReportTableError> {
let scope = scope.into();
let refuse = |detail: String| ReportTableError::InvalidRow {
table: self.name.clone(),
row: if group.is_empty() {
scope.to_string()
} else {
format!("{scope} [{}]", group.join("] ["))
},
detail,
};
if group.len() != self.group_by.len() {
return Err(refuse(format!(
"{} group value(s) for {} group column(s)",
group.len(),
self.group_by.len()
)));
}
if values.len() != self.columns.len() {
return Err(refuse(format!(
"{} value(s) for {} column(s)",
values.len(),
self.columns.len()
)));
}
for (value, column) in values.iter().zip(&self.columns) {
if let Some(detail) = value.misfit(column.kind) {
return Err(refuse(format!("column `{}`: {detail}", column.id)));
}
}
match self
.rows
.binary_search_by(|row| row.key().cmp(&(&scope, group.as_slice())))
{
Ok(_) => Err(refuse("a row about it exists already".into())),
Err(at) => {
self.rows.insert(
at,
ReportRow {
scope,
group,
values,
},
);
Ok(())
}
}
}
#[must_use]
pub fn with_rule_id(mut self, rule_id: RuleId) -> Self {
self.rule_id = rule_id;
self
}
#[must_use]
pub fn rule_id(&self) -> &RuleId {
&self.rule_id
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn group_by(&self) -> &[String] {
&self.group_by
}
#[must_use]
pub fn columns(&self) -> &[ReportColumn] {
&self.columns
}
#[must_use]
pub fn rows(&self) -> &[ReportRow] {
&self.rows
}
#[must_use]
pub fn row(&self, scope: &Scope) -> Option<&ReportRow> {
self.group_row(scope, &[])
}
#[must_use]
pub fn group_row(&self, scope: &Scope, group: &[String]) -> Option<&ReportRow> {
self.rows
.binary_search_by(|row| row.key().cmp(&(scope, group)))
.ok()
.map(|at| &self.rows[at])
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
}
fn token(name: String, kind: &'static str) -> Result<String, ReportTableError> {
let valid = (1..=64).contains(&name.len())
&& name
.bytes()
.next()
.is_some_and(|first| first.is_ascii_lowercase() || first.is_ascii_digit())
&& name.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"-_".contains(&byte)
});
if valid {
Ok(name)
} else {
Err(ReportTableError::InvalidName { kind, name })
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct TableWire {
rule_id: RuleId,
name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
group_by: Vec<String>,
columns: Vec<ReportColumn>,
rows: Vec<RowWire>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RowWire {
#[serde(default, skip_serializing_if = "Option::is_none")]
object_id: Option<ObjectId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
source: Option<SourceId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
group: Vec<String>,
values: Vec<ReportValue>,
}
impl From<ReportTable> for TableWire {
fn from(table: ReportTable) -> Self {
Self {
rule_id: table.rule_id,
name: table.name,
group_by: table.group_by,
columns: table.columns,
rows: table
.rows
.into_iter()
.map(|row| {
let (object_id, source) = row.scope.into_wire();
RowWire {
object_id,
source,
group: row.group,
values: row.values,
}
})
.collect(),
}
}
}
impl TryFrom<TableWire> for ReportTable {
type Error = String;
fn try_from(wire: TableWire) -> Result<Self, String> {
let mut table = Self::grouped(wire.rule_id, wire.name, wire.group_by, wire.columns)
.map_err(|error| error.to_string())?;
for row in wire.rows {
let scope = Scope::from_wire(row.object_id, row.source)?;
table
.push_group_row(scope, row.group, row.values)
.map_err(|error| error.to_string())?;
}
Ok(table)
}
}