tix/analytics/chart.rs
1//! The deterministic local chart document (mission `burndown-scope-analytics-01M3C9J1`,
2//! WP04 T019–T023).
3//!
4//! # What this module is
5//!
6//! [`ChartExport`] is the **portable document** form of one folded series: a versioned,
7//! local, read-only JSON payload carrying the scope and its period, the counting mode
8//! and unit, the ordered daily points, the optional end-date projection, the aggregate
9//! coverage tally, the documented formula version and every finding — the document half
10//! of `data-model.md` §6 and of `contracts/chart-export.md`. It is output, never state
11//! (C-001): nothing here opens a store, reads a clock, writes an issue, or creates a
12//! second analytics store.
13//!
14//! ```text
15//! BurndownSeries (+ optional Projection) ──ChartExport::build──▶ versioned local document
16//! ```
17//!
18//! # Mirrored, never re-derived (C-008)
19//!
20//! The shared values are **projected**, not recomputed. [`ChartExport`] carries a copy
21//! of the series' own scope, mode, unit and formula version, its points, its findings
22//! and its [`CoverageSummary`] — every number is a shared DTO field, read once (R9). The
23//! module does **no arithmetic at all**: it copies, sorts and names. The one place it
24//! could have drifted, a forecast, is a shared value too: [`ChartProjection`] mirrors
25//! WP03's [`Projection`] rather than drawing a second one.
26//!
27//! [`AnalyticsScope`] (WP01's `events.rs`) and [`BurndownSeries`] (WP02's `series.rs`)
28//! carry no serde derives and are owned by other packages, so the fields this document
29//! needs are **mirrored** into its own serde types ([`ChartScope`], [`ChartSeries`]) —
30//! the same portable-document precedent [`crate::capacity::portable`] and
31//! [`crate::time_tracking::portable`] follow. The shared leaf types that already derive
32//! serde ([`Mode`], [`Coverage`](crate::analytics::series::Coverage), [`DailyPoint`],
33//! [`CoverageSummary`], [`AnalyticsDiagnostic`], [`ProjectionStatus`]) are reused
34//! verbatim, so the wire vocabulary is the contract's own and cannot be re-spelled here.
35//!
36//! # The marker is read before the shape (T019)
37//!
38//! [`ChartExport::from_json`] reads the `format` marker **first**, so a payload that is
39//! not a chart document at all — a `tix-capacity`, `tix-roadmap`, `tix-health`,
40//! `tix-time` or `tix-views` document, whose fields overlap structurally — is refused
41//! **by name** rather than by whatever shape error its fields happened to raise. A
42//! document of a different [`SCHEMA_VERSION`] is read, its own fields kept — including
43//! the document-level keys this build does not interpret, which travel in
44//! [`ChartExport::unknown`] — and the mismatch is reported as a [`VERSION_MISMATCH`]
45//! finding (merged, never appended twice), which is the checked-and-reported third
46//! answer between refusing a newer writer and silently dropping its fields.
47//!
48//! # Preserved data has its own namespaces (T019)
49//!
50//! Data this build does not interpret travels in **two channels**, and neither can
51//! shadow a projected field:
52//!
53//! * the nested `extensions` object — the channel this build **writes**, through
54//! [`ChartExport::insert_extension`]. Because it is its own object, a preserved key
55//! named `format`, `series` or `scope` lands *inside* it and can never duplicate — or
56//! be read in place of — the projected field beside it, for this reader **or** for a
57//! lenient one. Flattening an extension bag onto a projected object is what produced a
58//! duplicate JSON key (and an unloadable file) in an earlier mission.
59//! * the flattened catch-all ([`ChartExport::unknown`]) — the channel this build **only
60//! reads**. serde delivers a key a newer writer placed directly on the document here,
61//! and it is re-emitted verbatim; that is what keeps the version finding's promise
62//! ("its fields are preserved rather than refused or dropped") true. `build` always
63//! starts it empty and there is no write side, so this build can never emit a second
64//! projected key: a reader cannot make it produce a document it then refuses.
65//!
66//! # Absent is `null`, never `0` (C-005, T021)
67//!
68//! Every unknown number is `null` on the wire (a serde `Option`), never a fabricated
69//! zero, and a day with no recorded history keeps its `known` / `coverage_limited` /
70//! `unknown` marker beside it. The document states `null` values plainly, so a consumer
71//! cannot mistake "not known" for "nothing happened".
72//!
73//! # Ordering and precision are fixed (T020)
74//!
75//! Days are the series' calendar order, findings are sorted by their own `Ord`, and both
76//! preserved-data channels are [`BTreeMap`]s, so key order is normalised. Every value is a
77//! whole number (`precision`): count mode counts whole issues and estimate mode counts
78//! whole converted units, because the shared conversion refuses a fractional result
79//! rather than rounding it ([`crate::wip::WipConfig::convert`]). The only fractional
80//! quantity in a document is a projection's `observed_rate`, and the projection node
81//! documents that beside it. Serialization is therefore a pure function of the input: the
82//! same series, mode and period produce identical bytes, and the bytes this build emits
83//! re-read as a byte fixpoint ([`ChartExport::to_json_pretty`] →
84//! [`ChartExport::from_json`] → [`ChartExport::to_json_pretty`]).
85//!
86//! # A closed snapshot is provenance, not a re-read (C-006, T022)
87//!
88//! The scope's `snapshot_id` is carried into [`ChartScope::snapshot_id`] and the fold's
89//! own [`ClosedSnapshot`](crate::analytics::diagnostics::AnalyticsDiagnosticKind::ClosedSnapshot)
90//! finding travels with the series' diagnostics, so a document says which closed view it
91//! describes. Nothing is recomputed from current state: an event recorded after the close
92//! is not part of the document's points, exactly as it is not part of the fold.
93//!
94//! # A recorded `estimate_changed` is reported, never replayed
95//!
96//! WP03's decision (`units.rs`, tracer `design-decisions`): an estimate change recorded
97//! inside the period is reported — its day is coverage-limited and the finding travels
98//! in the series' diagnostics — and is **not** replayed into the levels. This module
99//! inherits that by copying the series verbatim; it never re-values a recorded change,
100//! which would be a second formula and would double-count against the stated estimate
101//! every other contribution is valued at.
102//!
103//! # Remote namespaces stay separate (T022)
104//!
105//! `tix export --target gitlab|linear` mirrors **issues** to a remote tracker and shells
106//! out to `glab`/`linear-cli`; this document is a *local* interchange form and never
107//! reaches either tool. Where a remote target is configured, its inability to carry a
108//! chart or time-series field is **diagnosed explicitly** in the document
109//! ([`UNSUPPORTED_REMOTE_FIELD`], naming the target and the day counts that stay local),
110//! because a silent drop and an invented mapping are both worse than a named refusal.
111//! `src/sync/**` is untouched, and `chart` is dispatched by name before
112//! [`Tool::from_target`](crate::sync::Tool::from_target) is consulted.
113//!
114//! # Read-only, on every path
115//!
116//! Build, serialize and read back all take values and return values: no file is opened
117//! for writing, no `History` event is appended, and no issue is mutated (C-007).
118#![allow(clippy::module_name_repetitions)]
119
120use std::collections::BTreeMap;
121
122use serde::{Deserialize, Serialize};
123
124use crate::error::{Result, TixError};
125
126use super::diagnostics::AnalyticsDiagnostic;
127use super::events::AnalyticsScope;
128use super::projection::{Projection, ProjectionStatus};
129use super::series::{BurndownSeries, CoverageSummary, DailyPoint, Mode};
130
131/// The document's format marker. A consumer reads this before treating the payload as a
132/// chart document; a document with another marker is refused by name.
133pub const FORMAT: &str = "tix-chart";
134
135/// The document's schema version. Bumped when the shape changes incompatibly.
136pub const SCHEMA_VERSION: u32 = 1;
137
138/// The stable kind spelling of a "a configured remote target cannot carry chart data"
139/// finding.
140///
141/// The same spelling the health, roadmap, capacity and time portable documents use for
142/// the same finding, so a reader who learned one vocabulary reads the others. (`views`
143/// is the one local document with no such finding: it echoes its configured remotes
144/// rather than diagnosing a field a tracker cannot carry.)
145pub const UNSUPPORTED_REMOTE_FIELD: &str = "unsupported_remote_field";
146
147/// The stable kind spelling of a "different document version" finding.
148pub const VERSION_MISMATCH: &str = "version_mismatch";
149
150/// The precision of the series values under `mode` (`data-model.md` §3: "Values use the
151/// selected mode's precision and unit", T020).
152///
153/// Every value in a chart document is a **whole number**: count mode counts whole issues
154/// and estimate mode counts whole converted units, because the shared conversion refuses
155/// a fractional result rather than rounding it
156/// ([`crate::wip::WipConfig::convert`]). A record that could not be converted contributes
157/// nothing and is reported, so no level is ever a rounded number. The one fractional
158/// quantity a document may carry is a projection's `observed_rate`; the projection node
159/// says so beside it.
160#[must_use]
161pub const fn precision_of(mode: Mode) -> &'static str {
162 match mode {
163 Mode::Count => "whole_issues",
164 Mode::Estimate => "whole_units",
165 }
166}
167
168// ---------------------------------------------------------------------------
169// T019 — the projected nodes
170// ---------------------------------------------------------------------------
171
172/// The scope a document describes, in the document's own serde shape
173/// (`data-model.md` §1/§6).
174///
175/// A field-for-field mirror of WP01's [`AnalyticsScope`] — the kind, the stable id, the
176/// inclusive period and the closed-snapshot binding — because that type carries no serde
177/// derive and is owned by another package. `snapshot_id` is the closed-series
178/// provenance: when it is present the document describes a **closed** view whose period
179/// bounds it (C-006), so a later edit is not a contribution to it.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct ChartScope {
182 /// Which planning entity `id` names: the [`ScopeKind`](crate::analytics::events::ScopeKind)
183 /// spelling (`cycle` / `milestone`).
184 pub kind: String,
185 /// The stable planning identity (a cycle name, a milestone name).
186 pub id: String,
187 /// The inclusive period context.
188 pub period: ChartPeriod,
189 /// The closed snapshot this series is bound to, when it is bound to one.
190 #[serde(default)]
191 pub snapshot_id: Option<String>,
192}
193
194impl ChartScope {
195 /// Project a shared [`AnalyticsScope`] into the document's shape.
196 ///
197 /// A pure field copy: the kind is the scope's own canonical spelling, the period an
198 /// absent bound stays `null` (never an empty string or a `0`), and the snapshot
199 /// binding is carried verbatim. Nothing is resolved or inferred.
200 #[must_use]
201 pub fn from_scope(scope: &AnalyticsScope) -> Self {
202 Self {
203 kind: scope.kind.as_str().to_string(),
204 id: scope.id.clone(),
205 period: ChartPeriod {
206 start: scope.period.start.clone(),
207 end: scope.period.end.clone(),
208 },
209 snapshot_id: scope.snapshot_id.clone(),
210 }
211 }
212}
213
214/// The inclusive calendar period a document covers (`data-model.md` §1).
215///
216/// An absent bound stays `null` — "unbounded on that side" is coverage, not a guess.
217#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
218pub struct ChartPeriod {
219 /// Inclusive start, or `null` for "unbounded from the beginning".
220 #[serde(default)]
221 pub start: Option<String>,
222 /// Inclusive end, or `null` for "still open".
223 #[serde(default)]
224 pub end: Option<String>,
225}
226
227/// The ordered daily series, in the document's own serde shape (`data-model.md` §4).
228///
229/// A mirror of WP02's [`BurndownSeries`]: the scope, mode, unit, points, findings and
230/// formula version. The aggregate coverage tally is the **document's** (`data-model.md`
231/// §6), taken from the shared [`BurndownSeries::coverage`] accessor, so it is not
232/// repeated here.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234pub struct ChartSeries {
235 /// The selected cycle/milestone, its period and its snapshot binding.
236 pub scope: ChartScope,
237 /// How the series counts.
238 pub mode: Mode,
239 /// The unit the values are counted in, `null` in count mode (an issue count is
240 /// unitless).
241 #[serde(default)]
242 pub unit: Option<String>,
243 /// The ordered daily points, in calendar order, gaps included.
244 #[serde(default)]
245 pub points: Vec<DailyPoint>,
246 /// The findings, in the series' deterministic order.
247 #[serde(default)]
248 pub diagnostics: Vec<AnalyticsDiagnostic>,
249 /// The documented formula this series was folded under.
250 pub formula_version: String,
251}
252
253impl ChartSeries {
254 /// Project a shared [`BurndownSeries`] into the document's shape.
255 ///
256 /// A pure field copy: the points are the fold's own ordered days, the diagnostics are
257 /// the fold's own sorted findings, and the unit is `null` exactly when the series
258 /// says it is unitless. Nothing is recomputed and nothing is added.
259 #[must_use]
260 pub fn from_series(series: &BurndownSeries) -> Self {
261 Self {
262 scope: ChartScope::from_scope(&series.scope),
263 mode: series.mode,
264 unit: series.unit.clone(),
265 points: series.points.clone(),
266 diagnostics: series.diagnostics.clone(),
267 formula_version: series.formula_version.clone(),
268 }
269 }
270}
271
272/// The optional read-only end-date forecast, in the document's own serde shape
273/// (`data-model.md` §5).
274///
275/// A mirror of WP03's [`Projection`], which carries an [`AnalyticsScope`] and therefore
276/// no serde derive. `remaining` and `observed_rate` are in the series' own unit, which
277/// `mode`/`unit` carry so a reader never sees a rate without the unit it is expressed
278/// in; a value the series could not answer is `null`, and `status` says what the
279/// forecast could do.
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
281pub struct ChartProjection {
282 /// The selected cycle/milestone, period and snapshot binding.
283 pub scope: ChartScope,
284 /// How the series the projection was drawn from counts.
285 pub mode: Mode,
286 /// The unit the remaining work and the rate are expressed in, `null` in count mode.
287 #[serde(default)]
288 pub unit: Option<String>,
289 /// The forecast origin — the caller's day, never a clock.
290 pub as_of: String,
291 /// The planning end date the forecast is drawn against.
292 pub end_date: String,
293 /// The remaining work the series carried to `as_of`, in the series' unit.
294 #[serde(default)]
295 pub remaining: Option<i64>,
296 /// The observed completion rate, in the series' unit per calendar day.
297 ///
298 /// The document's only fractional quantity: every level and delta is whole (see
299 /// [`precision_of`]), while this quotient is not. It is the shared projection's own
300 /// value, copied — never re-computed here.
301 #[serde(default)]
302 pub observed_rate: Option<f64>,
303 /// The forecast completion date, when one follows.
304 #[serde(default)]
305 pub projected_completion: Option<String>,
306 /// Whether the projected completion falls on or before `end_date`; `null` when no
307 /// date was projected.
308 #[serde(default)]
309 pub meets_end_date: Option<bool>,
310 /// Whether the projection could answer, and with what.
311 pub status: ProjectionStatus,
312 /// The documented formula this projection was drawn under.
313 pub formula_version: String,
314 /// Why no date was projected, when one was not.
315 #[serde(default)]
316 pub diagnostics: Vec<AnalyticsDiagnostic>,
317}
318
319impl ChartProjection {
320 /// Project a shared [`Projection`] into the document's shape.
321 ///
322 /// A pure field copy: the status, the dates, the remaining work and the rate are the
323 /// projection's own values, and its findings travel verbatim. Nothing is re-derived,
324 /// so a chart document cannot disagree with `tix analytics project`.
325 #[must_use]
326 pub fn from_projection(projection: &Projection) -> Self {
327 Self {
328 scope: ChartScope::from_scope(&projection.scope),
329 mode: projection.mode,
330 unit: projection.unit.clone(),
331 as_of: projection.as_of.clone(),
332 end_date: projection.end_date.clone(),
333 remaining: projection.remaining,
334 observed_rate: projection.observed_rate,
335 projected_completion: projection.projected_completion.clone(),
336 meets_end_date: projection.meets_end_date,
337 status: projection.status,
338 formula_version: projection.formula_version.clone(),
339 diagnostics: projection.diagnostics.clone(),
340 }
341 }
342}
343
344/// One **document-level** finding, in the chart document's own vocabulary (T021/T022).
345///
346/// The series' and the projection's own findings are the shared
347/// [`AnalyticsDiagnostic`] values and travel inside their nodes; this shape carries what
348/// only the *export* can know — that a configured remote target cannot carry chart data,
349/// and that the document's schema version differs from this build's. `kind` is a plain
350/// string so a kind a newer build adds survives a round trip instead of failing to parse.
351#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
352pub struct ChartDiagnostic {
353 /// The stable contract name ([`UNSUPPORTED_REMOTE_FIELD`], [`VERSION_MISMATCH`], …).
354 pub kind: String,
355 /// The stable id of the scope the finding is about, when it is about one.
356 #[serde(default)]
357 pub scope: Option<String>,
358 /// The remote target, when the finding is about a mapping a target cannot carry.
359 #[serde(default)]
360 pub target: Option<String>,
361 /// What is wrong, in one sentence.
362 pub reason: String,
363 /// What the reader can do about it, when there is something.
364 #[serde(default)]
365 pub remedy: Option<String>,
366}
367
368impl ChartDiagnostic {
369 /// A document-level finding about a remote target that cannot carry chart data.
370 ///
371 /// The day counts are the shared [`CoverageSummary`]'s own — nothing is summed here
372 /// — so the sentence names real numbers and the reader can see how much stays local.
373 /// It is **not** a mapping: no remote id is invented, no value is converted, and
374 /// nothing is sent anywhere.
375 #[must_use]
376 fn unsupported_remote(target: &str, scope: &str, coverage: CoverageSummary) -> Self {
377 Self {
378 kind: UNSUPPORTED_REMOTE_FIELD.to_string(),
379 scope: Some(scope.to_string()),
380 target: Some(target.to_string()),
381 reason: format!(
382 "`{target}` has no chart or time-series field, so this document's {total} day \
383 row(s) — {known} known, {limited} coverage-limited, {unknown} unknown — stay \
384 local; nothing is mapped and nothing is dropped",
385 total = coverage.total(),
386 known = coverage.known,
387 limited = coverage.coverage_limited,
388 unknown = coverage.unknown,
389 ),
390 remedy: Some(
391 "read the chart document locally; a tracker field it names can be added when \
392 `--target {target}` can carry it"
393 .replace("{target}", target),
394 ),
395 }
396 }
397
398 /// A document-level finding about a schema version this build does not write.
399 #[must_use]
400 fn version_mismatch(found: u32, scope: &str) -> Self {
401 Self {
402 kind: VERSION_MISMATCH.to_string(),
403 scope: Some(scope.to_string()),
404 target: None,
405 reason: format!(
406 "the document declares schema_version {found}, but this build writes \
407 {SCHEMA_VERSION}; its fields are preserved rather than refused or dropped — a \
408 document-level key this build does not interpret is kept and re-emitted where \
409 it was read"
410 ),
411 remedy: Some(
412 "read it with a build that writes that version; this one preserves the fields \
413 it does not interpret"
414 .to_string(),
415 ),
416 }
417 }
418
419 /// The stable contract name.
420 #[must_use]
421 pub fn kind(&self) -> &str {
422 &self.kind
423 }
424
425 /// One line, `kind [target]: reason` — what a surface prints.
426 ///
427 /// A remote finding renders with its target in brackets so the rendered line names
428 /// the tracker and the promise that the values stay local; every other finding
429 /// renders as `kind: reason`.
430 #[must_use]
431 pub fn render(&self) -> String {
432 match self.target.as_deref() {
433 Some(target) => format!("{} [{target}]: {}", self.kind, self.reason),
434 None => format!("{}: {}", self.kind, self.reason),
435 }
436 }
437
438 /// Whether two findings describe the same problem, which is how a derived finding
439 /// is merged rather than appended twice on a repeated round trip.
440 #[must_use]
441 fn same_as(&self, other: &Self) -> bool {
442 self.kind == other.kind
443 && self.scope == other.scope
444 && self.target == other.target
445 && self.reason == other.reason
446 }
447}
448
449// ---------------------------------------------------------------------------
450// T019–T022 — the document
451// ---------------------------------------------------------------------------
452
453/// Everything a local chart surface knows, in one versioned document (`data-model.md`
454/// §6).
455///
456/// The scope, mode, unit, formula version and coverage are the document's own top-level
457/// metadata — required on every export (NFR-002), so a reader never has to descend into
458/// the series to learn the period or the unit — and they are built from the **same**
459/// source as the series' own fields, so the two cannot disagree.
460#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
461pub struct ChartExport {
462 /// The format marker: [`FORMAT`].
463 pub format: String,
464 /// The schema version: [`SCHEMA_VERSION`].
465 pub schema_version: u32,
466 /// The scope this document describes: its kind, id, period and snapshot binding.
467 pub scope: ChartScope,
468 /// How the series counts.
469 pub mode: Mode,
470 /// The unit the values are counted in; `null` in count mode.
471 #[serde(default)]
472 pub unit: Option<String>,
473 /// The precision every series value is stated in ([`precision_of`]).
474 pub precision: String,
475 /// The ordered daily series.
476 pub series: ChartSeries,
477 /// The end-date forecast, when the caller asked for one.
478 #[serde(default)]
479 pub projection: Option<ChartProjection>,
480 /// The aggregate coverage tally of the series' days.
481 pub coverage: CoverageSummary,
482 /// The documented formula the series was folded under.
483 pub formula_version: String,
484 /// The document-level findings (remote targets, version).
485 #[serde(default)]
486 pub diagnostics: Vec<ChartDiagnostic>,
487 /// Data this build does not interpret, in a namespace of its own.
488 ///
489 /// Private, so the only public write path is [`Self::insert_extension`], which
490 /// cannot produce a document this build cannot read: the key lands in its own
491 /// object and can never repeat — or be read in place of — a projected field.
492 #[serde(default)]
493 extensions: BTreeMap<String, serde_json::Value>,
494 /// Keys a newer writer placed directly on the document, preserved verbatim.
495 ///
496 /// The **read-only** half of the preserved-data story (see the module docs), and the
497 /// reason the version finding's promise holds: a `tix-chart` document of another
498 /// schema version can add a document-level field this build does not project, and it
499 /// is re-emitted where it was read instead of silently dropped.
500 ///
501 /// `build` always starts it empty and there is no write side, so this build never
502 /// emits a key it also projects — the emitted document is always one this build
503 /// reads back, unchanged. Read through [`Self::unknown`].
504 #[serde(flatten)]
505 unknown: BTreeMap<String, serde_json::Value>,
506}
507
508/// The marker-only prefix of a document, read before the document itself.
509///
510/// A payload that is not a chart document is refused **by name** rather than by whatever
511/// shape error its fields happened to raise against this one's.
512#[derive(Deserialize)]
513struct Marker {
514 format: String,
515}
516
517impl ChartExport {
518 /// Build the document for one folded series, with the findings the export adds.
519 ///
520 /// `series` is the one series shape every surface folds (C-008), so a document's
521 /// numbers cannot become a second reading of the repository. `projection` is the
522 /// shared projection the caller drew from the **same** series, when the caller asked
523 /// for a forecast. `remote_targets` are the configured remote trackers
524 /// (`gitlab`/`linear`) that cannot carry chart data: one
525 /// [`UNSUPPORTED_REMOTE_FIELD`] finding each, naming the target and the day counts
526 /// that stay local.
527 #[must_use]
528 pub fn build(
529 series: &BurndownSeries,
530 projection: Option<&Projection>,
531 remote_targets: &[&str],
532 ) -> Self {
533 let coverage = series.coverage();
534 let mut diagnostics: Vec<ChartDiagnostic> = remote_targets
535 .iter()
536 .map(|target| ChartDiagnostic::unsupported_remote(target, &series.scope.id, coverage))
537 .collect();
538 diagnostics.sort();
539 Self {
540 format: FORMAT.to_string(),
541 schema_version: SCHEMA_VERSION,
542 scope: ChartScope::from_scope(&series.scope),
543 mode: series.mode,
544 unit: series.unit.clone(),
545 precision: precision_of(series.mode).to_string(),
546 series: ChartSeries::from_series(series),
547 projection: projection.map(ChartProjection::from_projection),
548 coverage,
549 formula_version: series.formula_version.clone(),
550 diagnostics,
551 extensions: BTreeMap::new(),
552 unknown: BTreeMap::new(),
553 }
554 }
555
556 /// Whether this document carries no day at all.
557 #[must_use]
558 pub fn is_empty(&self) -> bool {
559 self.series.points.is_empty()
560 }
561
562 /// The document's format marker.
563 #[must_use]
564 pub fn format(&self) -> &str {
565 &self.format
566 }
567
568 /// The document's schema version.
569 #[must_use]
570 pub const fn schema_version(&self) -> u32 {
571 self.schema_version
572 }
573
574 /// Preserve one document-level key, in the nested `extensions` object.
575 ///
576 /// The **only** public write path into the bag, and it cannot produce a document
577 /// this build cannot read: the key lands in a namespace of its own, so it can never
578 /// duplicate — or be read in place of — a document field.
579 pub fn insert_extension(&mut self, key: impl Into<String>, value: serde_json::Value) {
580 self.extensions.insert(key.into(), value);
581 }
582
583 /// The preserved keys this build does not interpret, in canonical (sorted) order.
584 pub fn extensions(&self) -> impl Iterator<Item = (&str, &serde_json::Value)> {
585 self.extensions
586 .iter()
587 .map(|(key, value)| (key.as_str(), value))
588 }
589
590 /// The keys a newer writer placed directly on the document, preserved verbatim.
591 ///
592 /// The read side of the document's catch-all channel: empty for a document this build
593 /// built, and holding whatever a foreign writer's document carried at its own top
594 /// level otherwise. There is no write side, and the bag is private, which is what
595 /// makes a document-level key naming a projected field impossible to produce through
596 /// this API.
597 #[must_use]
598 pub fn unknown(&self) -> &BTreeMap<String, serde_json::Value> {
599 &self.unknown
600 }
601
602 /// Serialize the document as compact JSON.
603 pub fn to_json(&self) -> Result<String> {
604 serde_json::to_string(self).map_err(|error| {
605 TixError::Parse(format!("chart document cannot be serialized: {error}"))
606 })
607 }
608
609 /// Serialize the document as pretty JSON — the export surface's shape.
610 pub fn to_json_pretty(&self) -> Result<String> {
611 serde_json::to_string_pretty(self).map_err(|error| {
612 TixError::Parse(format!("chart document cannot be serialized: {error}"))
613 })
614 }
615
616 /// Read a document back.
617 ///
618 /// The [`Marker`] is read **first**: a document that is not a chart document is
619 /// refused by name (its format marker) rather than by whatever shape error its fields
620 /// happened to raise — a `tix-capacity`, `tix-time` or `tix-roadmap` payload overlaps
621 /// structurally and would otherwise produce a misleading field error. A document of
622 /// another *version* is read, its own fields kept — every document-level key this
623 /// build does not project travels in [`Self::unknown`] and is re-emitted verbatim —
624 /// and the mismatch is merged into the diagnostics once, the checked-and-reported
625 /// third answer between refusing a newer writer and silently dropping its fields.
626 ///
627 /// This build never writes a projected key twice — preserved keys live in their own
628 /// nested object, and the document-level catch-all is empty on every path that builds
629 /// a document — so its own output always round-trips: the emitted bytes re-read as a
630 /// byte fixpoint.
631 pub fn from_json(raw: &str) -> Result<Self> {
632 let marker: Marker = serde_json::from_str(raw)
633 .map_err(|error| TixError::Parse(format!("not a portable chart document: {error}")))?;
634 if marker.format != FORMAT {
635 return Err(TixError::Parse(format!(
636 "portable chart document has format `{}`, but this build reads `{FORMAT}`",
637 marker.format
638 )));
639 }
640 let mut document: Self = serde_json::from_str(raw).map_err(|error| {
641 TixError::Parse(format!(
642 "portable chart document is not valid JSON of this shape: {error}"
643 ))
644 })?;
645 if document.schema_version != SCHEMA_VERSION {
646 let diagnostic =
647 ChartDiagnostic::version_mismatch(document.schema_version, &document.scope.id);
648 if !document
649 .diagnostics
650 .iter()
651 .any(|existing| existing.same_as(&diagnostic))
652 {
653 document.diagnostics.push(diagnostic);
654 document.diagnostics.sort();
655 }
656 }
657 Ok(document)
658 }
659}