Skip to main content

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}