use crate::{MeasurementDirection, MeasurementKind, ReportContext};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, error::Error, fmt};
pub const SERIES_DOCUMENT_TYPE: &str = "micromeasure-series";
pub const SERIES_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ValidityStatus {
#[default]
Valid,
Invalid,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct Validity {
pub status: ValidityStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl Validity {
pub fn valid() -> Self {
Self::default()
}
pub fn invalid(reason: impl Into<String>) -> Self {
Self {
status: ValidityStatus::Invalid,
reason: Some(reason.into()),
}
}
pub fn is_valid(&self) -> bool {
self.status == ValidityStatus::Valid
}
fn validate(&self, location: &str) -> Result<(), SeriesValidationError> {
if self.status == ValidityStatus::Invalid
&& self
.reason
.as_deref()
.is_none_or(|reason| reason.trim().is_empty())
{
return Err(SeriesValidationError::new(format!(
"{location} validity is invalid but has no non-empty reason"
)));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct SeriesResult {
pub group: String,
pub name: String,
pub measurement: MeasurementKind,
pub unit: String,
pub direction: MeasurementDirection,
pub samples: Vec<f64>,
pub validity: Validity,
#[serde(default)]
pub dimensions: BTreeMap<String, String>,
#[serde(default)]
pub provenance: BTreeMap<String, String>,
}
impl SeriesResult {
pub fn new(
group: impl Into<String>,
name: impl Into<String>,
measurement: MeasurementKind,
unit: impl Into<String>,
direction: MeasurementDirection,
samples: Vec<f64>,
) -> Self {
Self {
group: group.into(),
name: name.into(),
measurement,
unit: unit.into(),
direction,
samples,
validity: Validity::valid(),
dimensions: BTreeMap::new(),
provenance: BTreeMap::new(),
}
}
pub fn with_validity(mut self, validity: Validity) -> Self {
self.validity = validity;
self
}
pub fn with_dimension(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.dimensions.insert(key.into(), value.into());
self
}
pub fn with_provenance(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.provenance.insert(key.into(), value.into());
self
}
fn validate(&self, index: usize) -> Result<(), SeriesValidationError> {
let location = format!("result {index} ({}/{})", self.group, self.name);
validate_nonempty("group", &self.group, &location)?;
validate_nonempty("name", &self.name, &location)?;
validate_nonempty("unit", &self.unit, &location)?;
self.validity.validate(&location)?;
validate_map("dimensions", &self.dimensions, &location)?;
validate_map("provenance", &self.provenance, &location)?;
if self.validity.is_valid() && self.samples.is_empty() {
return Err(SeriesValidationError::new(format!(
"{location} is valid but has no samples"
)));
}
if self.samples.iter().any(|sample| !sample.is_finite()) {
return Err(SeriesValidationError::new(format!(
"{location} contains a non-finite sample"
)));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct SeriesReport {
pub document_type: String,
pub schema_version: u32,
pub timestamp: String,
pub suite: String,
pub validity: Validity,
pub context: ReportContext,
pub results: Vec<SeriesResult>,
}
impl SeriesReport {
pub fn new(
timestamp: impl Into<String>,
suite: impl Into<String>,
context: ReportContext,
results: Vec<SeriesResult>,
) -> Self {
Self {
document_type: SERIES_DOCUMENT_TYPE.to_string(),
schema_version: SERIES_SCHEMA_VERSION,
timestamp: timestamp.into(),
suite: suite.into(),
validity: Validity::valid(),
context,
results,
}
}
pub fn with_validity(mut self, validity: Validity) -> Self {
self.validity = validity;
self
}
pub fn validate(&self) -> Result<(), SeriesValidationError> {
if self.document_type != SERIES_DOCUMENT_TYPE {
return Err(SeriesValidationError::new(format!(
"document_type must be {SERIES_DOCUMENT_TYPE:?}, found {:?}",
self.document_type
)));
}
if self.schema_version != SERIES_SCHEMA_VERSION {
return Err(SeriesValidationError::new(format!(
"unsupported series schema version {}; supported version is {}",
self.schema_version, SERIES_SCHEMA_VERSION
)));
}
validate_nonempty("timestamp", &self.timestamp, "report")?;
validate_nonempty("suite", &self.suite, "report")?;
validate_nonempty("runner_id", &self.context.runner_id, "report context")?;
self.context
.validate()
.map_err(|error| SeriesValidationError::new(error.to_string()))?;
self.validity.validate("report")?;
if self.validity.is_valid() && self.results.is_empty() {
return Err(SeriesValidationError::new(
"valid series report must contain at least one result",
));
}
for (index, result) in self.results.iter().enumerate() {
result.validate(index)?;
}
Ok(())
}
}
fn validate_nonempty(
field: &str,
value: &str,
location: &str,
) -> Result<(), SeriesValidationError> {
if value.trim().is_empty() {
return Err(SeriesValidationError::new(format!(
"{location} {field} must not be empty"
)));
}
Ok(())
}
fn validate_map(
name: &str,
values: &BTreeMap<String, String>,
location: &str,
) -> Result<(), SeriesValidationError> {
for (key, value) in values {
if key.trim().is_empty() {
return Err(SeriesValidationError::new(format!(
"{location} {name} keys must not be empty"
)));
}
if value.trim().is_empty() {
return Err(SeriesValidationError::new(format!(
"{location} {name} value for {key:?} must not be empty"
)));
}
}
Ok(())
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SeriesValidationError {
message: String,
}
impl SeriesValidationError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for SeriesValidationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl Error for SeriesValidationError {}
#[cfg(test)]
mod tests {
use super::*;
fn valid_result() -> SeriesResult {
SeriesResult::new(
"lifecycle",
"create",
MeasurementKind::Latency,
"ms",
MeasurementDirection::Lower,
vec![10.0, 11.0, 12.0],
)
}
fn valid_report() -> SeriesReport {
SeriesReport::new(
"2026-07-27T15:10:00Z",
"container-lifecycle",
ReportContext::new("runner-a"),
vec![valid_result()],
)
}
#[test]
fn valid_series_passes_semantic_validation() {
valid_report().validate().unwrap();
}
#[test]
fn interchange_types_reject_unknown_fields() {
let report = serde_json::to_value(valid_report()).unwrap();
let mut unknown_report = report.clone();
unknown_report["unexpected"] = serde_json::json!(true);
assert!(serde_json::from_value::<SeriesReport>(unknown_report).is_err());
let mut unknown_result = report.clone();
unknown_result["results"][0]["unexpected"] = serde_json::json!(true);
assert!(serde_json::from_value::<SeriesReport>(unknown_result).is_err());
let mut unknown_validity = report.clone();
unknown_validity["results"][0]["validity"]["unexpected"] = serde_json::json!(true);
assert!(serde_json::from_value::<SeriesReport>(unknown_validity).is_err());
let mut unknown_context = report;
unknown_context["context"]["enviroment"] = serde_json::json!({});
assert!(serde_json::from_value::<SeriesReport>(unknown_context).is_err());
}
#[test]
fn invalid_validity_requires_a_reason() {
let mut report = valid_report();
report.results[0].validity = Validity {
status: ValidityStatus::Invalid,
reason: None,
};
assert!(
report
.validate()
.unwrap_err()
.to_string()
.contains("non-empty reason")
);
}
#[test]
fn invalid_results_may_have_no_timing_samples() {
let mut report = valid_report();
report.results[0].samples.clear();
report.results[0].validity = Validity::invalid("checksum mismatch");
report.validate().unwrap();
}
#[test]
fn valid_results_require_samples() {
let mut report = valid_report();
report.results[0].samples.clear();
assert!(
report
.validate()
.unwrap_err()
.to_string()
.contains("has no samples")
);
}
#[test]
fn invalid_reports_may_have_no_results() {
let mut report = valid_report();
report.results.clear();
report.validity = Validity::invalid("shared setup failed");
report.validate().unwrap();
}
}