#![allow(clippy::module_name_repetitions)]
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::error::{Result, TixError};
use super::diagnostics::AnalyticsDiagnostic;
use super::events::AnalyticsScope;
use super::projection::{Projection, ProjectionStatus};
use super::series::{BurndownSeries, CoverageSummary, DailyPoint, Mode};
pub const FORMAT: &str = "tix-chart";
pub const SCHEMA_VERSION: u32 = 1;
pub const UNSUPPORTED_REMOTE_FIELD: &str = "unsupported_remote_field";
pub const VERSION_MISMATCH: &str = "version_mismatch";
#[must_use]
pub const fn precision_of(mode: Mode) -> &'static str {
match mode {
Mode::Count => "whole_issues",
Mode::Estimate => "whole_units",
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChartScope {
pub kind: String,
pub id: String,
pub period: ChartPeriod,
#[serde(default)]
pub snapshot_id: Option<String>,
}
impl ChartScope {
#[must_use]
pub fn from_scope(scope: &AnalyticsScope) -> Self {
Self {
kind: scope.kind.as_str().to_string(),
id: scope.id.clone(),
period: ChartPeriod {
start: scope.period.start.clone(),
end: scope.period.end.clone(),
},
snapshot_id: scope.snapshot_id.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ChartPeriod {
#[serde(default)]
pub start: Option<String>,
#[serde(default)]
pub end: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChartSeries {
pub scope: ChartScope,
pub mode: Mode,
#[serde(default)]
pub unit: Option<String>,
#[serde(default)]
pub points: Vec<DailyPoint>,
#[serde(default)]
pub diagnostics: Vec<AnalyticsDiagnostic>,
pub formula_version: String,
}
impl ChartSeries {
#[must_use]
pub fn from_series(series: &BurndownSeries) -> Self {
Self {
scope: ChartScope::from_scope(&series.scope),
mode: series.mode,
unit: series.unit.clone(),
points: series.points.clone(),
diagnostics: series.diagnostics.clone(),
formula_version: series.formula_version.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChartProjection {
pub scope: ChartScope,
pub mode: Mode,
#[serde(default)]
pub unit: Option<String>,
pub as_of: String,
pub end_date: String,
#[serde(default)]
pub remaining: Option<i64>,
#[serde(default)]
pub observed_rate: Option<f64>,
#[serde(default)]
pub projected_completion: Option<String>,
#[serde(default)]
pub meets_end_date: Option<bool>,
pub status: ProjectionStatus,
pub formula_version: String,
#[serde(default)]
pub diagnostics: Vec<AnalyticsDiagnostic>,
}
impl ChartProjection {
#[must_use]
pub fn from_projection(projection: &Projection) -> Self {
Self {
scope: ChartScope::from_scope(&projection.scope),
mode: projection.mode,
unit: projection.unit.clone(),
as_of: projection.as_of.clone(),
end_date: projection.end_date.clone(),
remaining: projection.remaining,
observed_rate: projection.observed_rate,
projected_completion: projection.projected_completion.clone(),
meets_end_date: projection.meets_end_date,
status: projection.status,
formula_version: projection.formula_version.clone(),
diagnostics: projection.diagnostics.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ChartDiagnostic {
pub kind: String,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub target: Option<String>,
pub reason: String,
#[serde(default)]
pub remedy: Option<String>,
}
impl ChartDiagnostic {
#[must_use]
fn unsupported_remote(target: &str, scope: &str, coverage: CoverageSummary) -> Self {
Self {
kind: UNSUPPORTED_REMOTE_FIELD.to_string(),
scope: Some(scope.to_string()),
target: Some(target.to_string()),
reason: format!(
"`{target}` has no chart or time-series field, so this document's {total} day \
row(s) — {known} known, {limited} coverage-limited, {unknown} unknown — stay \
local; nothing is mapped and nothing is dropped",
total = coverage.total(),
known = coverage.known,
limited = coverage.coverage_limited,
unknown = coverage.unknown,
),
remedy: Some(
"read the chart document locally; a tracker field it names can be added when \
`--target {target}` can carry it"
.replace("{target}", target),
),
}
}
#[must_use]
fn version_mismatch(found: u32, scope: &str) -> Self {
Self {
kind: VERSION_MISMATCH.to_string(),
scope: Some(scope.to_string()),
target: None,
reason: format!(
"the document declares schema_version {found}, but this build writes \
{SCHEMA_VERSION}; its fields are preserved rather than refused or dropped — a \
document-level key this build does not interpret is kept and re-emitted where \
it was read"
),
remedy: Some(
"read it with a build that writes that version; this one preserves the fields \
it does not interpret"
.to_string(),
),
}
}
#[must_use]
pub fn kind(&self) -> &str {
&self.kind
}
#[must_use]
pub fn render(&self) -> String {
match self.target.as_deref() {
Some(target) => format!("{} [{target}]: {}", self.kind, self.reason),
None => format!("{}: {}", self.kind, self.reason),
}
}
#[must_use]
fn same_as(&self, other: &Self) -> bool {
self.kind == other.kind
&& self.scope == other.scope
&& self.target == other.target
&& self.reason == other.reason
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChartExport {
pub format: String,
pub schema_version: u32,
pub scope: ChartScope,
pub mode: Mode,
#[serde(default)]
pub unit: Option<String>,
pub precision: String,
pub series: ChartSeries,
#[serde(default)]
pub projection: Option<ChartProjection>,
pub coverage: CoverageSummary,
pub formula_version: String,
#[serde(default)]
pub diagnostics: Vec<ChartDiagnostic>,
#[serde(default)]
extensions: BTreeMap<String, serde_json::Value>,
#[serde(flatten)]
unknown: BTreeMap<String, serde_json::Value>,
}
#[derive(Deserialize)]
struct Marker {
format: String,
}
impl ChartExport {
#[must_use]
pub fn build(
series: &BurndownSeries,
projection: Option<&Projection>,
remote_targets: &[&str],
) -> Self {
let coverage = series.coverage();
let mut diagnostics: Vec<ChartDiagnostic> = remote_targets
.iter()
.map(|target| ChartDiagnostic::unsupported_remote(target, &series.scope.id, coverage))
.collect();
diagnostics.sort();
Self {
format: FORMAT.to_string(),
schema_version: SCHEMA_VERSION,
scope: ChartScope::from_scope(&series.scope),
mode: series.mode,
unit: series.unit.clone(),
precision: precision_of(series.mode).to_string(),
series: ChartSeries::from_series(series),
projection: projection.map(ChartProjection::from_projection),
coverage,
formula_version: series.formula_version.clone(),
diagnostics,
extensions: BTreeMap::new(),
unknown: BTreeMap::new(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.series.points.is_empty()
}
#[must_use]
pub fn format(&self) -> &str {
&self.format
}
#[must_use]
pub const fn schema_version(&self) -> u32 {
self.schema_version
}
pub fn insert_extension(&mut self, key: impl Into<String>, value: serde_json::Value) {
self.extensions.insert(key.into(), value);
}
pub fn extensions(&self) -> impl Iterator<Item = (&str, &serde_json::Value)> {
self.extensions
.iter()
.map(|(key, value)| (key.as_str(), value))
}
#[must_use]
pub fn unknown(&self) -> &BTreeMap<String, serde_json::Value> {
&self.unknown
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string(self).map_err(|error| {
TixError::Parse(format!("chart document cannot be serialized: {error}"))
})
}
pub fn to_json_pretty(&self) -> Result<String> {
serde_json::to_string_pretty(self).map_err(|error| {
TixError::Parse(format!("chart document cannot be serialized: {error}"))
})
}
pub fn from_json(raw: &str) -> Result<Self> {
let marker: Marker = serde_json::from_str(raw)
.map_err(|error| TixError::Parse(format!("not a portable chart document: {error}")))?;
if marker.format != FORMAT {
return Err(TixError::Parse(format!(
"portable chart document has format `{}`, but this build reads `{FORMAT}`",
marker.format
)));
}
let mut document: Self = serde_json::from_str(raw).map_err(|error| {
TixError::Parse(format!(
"portable chart document is not valid JSON of this shape: {error}"
))
})?;
if document.schema_version != SCHEMA_VERSION {
let diagnostic =
ChartDiagnostic::version_mismatch(document.schema_version, &document.scope.id);
if !document
.diagnostics
.iter()
.any(|existing| existing.same_as(&diagnostic))
{
document.diagnostics.push(diagnostic);
document.diagnostics.sort();
}
}
Ok(document)
}
}