tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
//! The deterministic local chart document (mission `burndown-scope-analytics-01M3C9J1`,
//! WP04 T019–T023).
//!
//! # What this module is
//!
//! [`ChartExport`] is the **portable document** form of one folded series: a versioned,
//! local, read-only JSON payload carrying the scope and its period, the counting mode
//! and unit, the ordered daily points, the optional end-date projection, the aggregate
//! coverage tally, the documented formula version and every finding — the document half
//! of `data-model.md` §6 and of `contracts/chart-export.md`. It is output, never state
//! (C-001): nothing here opens a store, reads a clock, writes an issue, or creates a
//! second analytics store.
//!
//! ```text
//! BurndownSeries (+ optional Projection) ──ChartExport::build──▶ versioned local document
//! ```
//!
//! # Mirrored, never re-derived (C-008)
//!
//! The shared values are **projected**, not recomputed. [`ChartExport`] carries a copy
//! of the series' own scope, mode, unit and formula version, its points, its findings
//! and its [`CoverageSummary`] — every number is a shared DTO field, read once (R9). The
//! module does **no arithmetic at all**: it copies, sorts and names. The one place it
//! could have drifted, a forecast, is a shared value too: [`ChartProjection`] mirrors
//! WP03's [`Projection`] rather than drawing a second one.
//!
//! [`AnalyticsScope`] (WP01's `events.rs`) and [`BurndownSeries`] (WP02's `series.rs`)
//! carry no serde derives and are owned by other packages, so the fields this document
//! needs are **mirrored** into its own serde types ([`ChartScope`], [`ChartSeries`]) —
//! the same portable-document precedent [`crate::capacity::portable`] and
//! [`crate::time_tracking::portable`] follow. The shared leaf types that already derive
//! serde ([`Mode`], [`Coverage`](crate::analytics::series::Coverage), [`DailyPoint`],
//! [`CoverageSummary`], [`AnalyticsDiagnostic`], [`ProjectionStatus`]) are reused
//! verbatim, so the wire vocabulary is the contract's own and cannot be re-spelled here.
//!
//! # The marker is read before the shape (T019)
//!
//! [`ChartExport::from_json`] reads the `format` marker **first**, so a payload that is
//! not a chart document at all — a `tix-capacity`, `tix-roadmap`, `tix-health`,
//! `tix-time` or `tix-views` document, whose fields overlap structurally — is refused
//! **by name** rather than by whatever shape error its fields happened to raise. A
//! document of a different [`SCHEMA_VERSION`] is read, its own fields kept — including
//! the document-level keys this build does not interpret, which travel in
//! [`ChartExport::unknown`] — and the mismatch is reported as a [`VERSION_MISMATCH`]
//! finding (merged, never appended twice), which is the checked-and-reported third
//! answer between refusing a newer writer and silently dropping its fields.
//!
//! # Preserved data has its own namespaces (T019)
//!
//! Data this build does not interpret travels in **two channels**, and neither can
//! shadow a projected field:
//!
//! * the nested `extensions` object — the channel this build **writes**, through
//!   [`ChartExport::insert_extension`]. Because it is its own object, a preserved key
//!   named `format`, `series` or `scope` lands *inside* it and can never duplicate — or
//!   be read in place of — the projected field beside it, for this reader **or** for a
//!   lenient one. Flattening an extension bag onto a projected object is what produced a
//!   duplicate JSON key (and an unloadable file) in an earlier mission.
//! * the flattened catch-all ([`ChartExport::unknown`]) — the channel this build **only
//!   reads**. serde delivers a key a newer writer placed directly on the document here,
//!   and it is re-emitted verbatim; that is what keeps the version finding's promise
//!   ("its fields are preserved rather than refused or dropped") true. `build` always
//!   starts it empty and there is no write side, so this build can never emit a second
//!   projected key: a reader cannot make it produce a document it then refuses.
//!
//! # Absent is `null`, never `0` (C-005, T021)
//!
//! Every unknown number is `null` on the wire (a serde `Option`), never a fabricated
//! zero, and a day with no recorded history keeps its `known` / `coverage_limited` /
//! `unknown` marker beside it. The document states `null` values plainly, so a consumer
//! cannot mistake "not known" for "nothing happened".
//!
//! # Ordering and precision are fixed (T020)
//!
//! Days are the series' calendar order, findings are sorted by their own `Ord`, and both
//! preserved-data channels are [`BTreeMap`]s, so key order is normalised. Every value is a
//! whole number (`precision`): count mode counts whole issues and estimate mode counts
//! whole converted units, because the shared conversion refuses a fractional result
//! rather than rounding it ([`crate::wip::WipConfig::convert`]). The only fractional
//! quantity in a document is a projection's `observed_rate`, and the projection node
//! documents that beside it. Serialization is therefore a pure function of the input: the
//! same series, mode and period produce identical bytes, and the bytes this build emits
//! re-read as a byte fixpoint ([`ChartExport::to_json_pretty`] →
//! [`ChartExport::from_json`] → [`ChartExport::to_json_pretty`]).
//!
//! # A closed snapshot is provenance, not a re-read (C-006, T022)
//!
//! The scope's `snapshot_id` is carried into [`ChartScope::snapshot_id`] and the fold's
//! own [`ClosedSnapshot`](crate::analytics::diagnostics::AnalyticsDiagnosticKind::ClosedSnapshot)
//! finding travels with the series' diagnostics, so a document says which closed view it
//! describes. Nothing is recomputed from current state: an event recorded after the close
//! is not part of the document's points, exactly as it is not part of the fold.
//!
//! # A recorded `estimate_changed` is reported, never replayed
//!
//! WP03's decision (`units.rs`, tracer `design-decisions`): an estimate change recorded
//! inside the period is reported — its day is coverage-limited and the finding travels
//! in the series' diagnostics — and is **not** replayed into the levels. This module
//! inherits that by copying the series verbatim; it never re-values a recorded change,
//! which would be a second formula and would double-count against the stated estimate
//! every other contribution is valued at.
//!
//! # Remote namespaces stay separate (T022)
//!
//! `tix export --target gitlab|linear` mirrors **issues** to a remote tracker and shells
//! out to `glab`/`linear-cli`; this document is a *local* interchange form and never
//! reaches either tool. Where a remote target is configured, its inability to carry a
//! chart or time-series field is **diagnosed explicitly** in the document
//! ([`UNSUPPORTED_REMOTE_FIELD`], naming the target and the day counts that stay local),
//! because a silent drop and an invented mapping are both worse than a named refusal.
//! `src/sync/**` is untouched, and `chart` is dispatched by name before
//! [`Tool::from_target`](crate::sync::Tool::from_target) is consulted.
//!
//! # Read-only, on every path
//!
//! Build, serialize and read back all take values and return values: no file is opened
//! for writing, no `History` event is appended, and no issue is mutated (C-007).
#![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};

/// The document's format marker. A consumer reads this before treating the payload as a
/// chart document; a document with another marker is refused by name.
pub const FORMAT: &str = "tix-chart";

/// The document's schema version. Bumped when the shape changes incompatibly.
pub const SCHEMA_VERSION: u32 = 1;

/// The stable kind spelling of a "a configured remote target cannot carry chart data"
/// finding.
///
/// The same spelling the health, roadmap, capacity and time portable documents use for
/// the same finding, so a reader who learned one vocabulary reads the others. (`views`
/// is the one local document with no such finding: it echoes its configured remotes
/// rather than diagnosing a field a tracker cannot carry.)
pub const UNSUPPORTED_REMOTE_FIELD: &str = "unsupported_remote_field";

/// The stable kind spelling of a "different document version" finding.
pub const VERSION_MISMATCH: &str = "version_mismatch";

/// The precision of the series values under `mode` (`data-model.md` §3: "Values use the
/// selected mode's precision and unit", T020).
///
/// Every value in a chart document is a **whole number**: count mode counts whole issues
/// and estimate mode counts whole converted units, because the shared conversion refuses
/// a fractional result rather than rounding it
/// ([`crate::wip::WipConfig::convert`]). A record that could not be converted contributes
/// nothing and is reported, so no level is ever a rounded number. The one fractional
/// quantity a document may carry is a projection's `observed_rate`; the projection node
/// says so beside it.
#[must_use]
pub const fn precision_of(mode: Mode) -> &'static str {
    match mode {
        Mode::Count => "whole_issues",
        Mode::Estimate => "whole_units",
    }
}

// ---------------------------------------------------------------------------
// T019 — the projected nodes
// ---------------------------------------------------------------------------

/// The scope a document describes, in the document's own serde shape
/// (`data-model.md` §1/§6).
///
/// A field-for-field mirror of WP01's [`AnalyticsScope`] — the kind, the stable id, the
/// inclusive period and the closed-snapshot binding — because that type carries no serde
/// derive and is owned by another package. `snapshot_id` is the closed-series
/// provenance: when it is present the document describes a **closed** view whose period
/// bounds it (C-006), so a later edit is not a contribution to it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChartScope {
    /// Which planning entity `id` names: the [`ScopeKind`](crate::analytics::events::ScopeKind)
    /// spelling (`cycle` / `milestone`).
    pub kind: String,
    /// The stable planning identity (a cycle name, a milestone name).
    pub id: String,
    /// The inclusive period context.
    pub period: ChartPeriod,
    /// The closed snapshot this series is bound to, when it is bound to one.
    #[serde(default)]
    pub snapshot_id: Option<String>,
}

impl ChartScope {
    /// Project a shared [`AnalyticsScope`] into the document's shape.
    ///
    /// A pure field copy: the kind is the scope's own canonical spelling, the period an
    /// absent bound stays `null` (never an empty string or a `0`), and the snapshot
    /// binding is carried verbatim. Nothing is resolved or inferred.
    #[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(),
        }
    }
}

/// The inclusive calendar period a document covers (`data-model.md` §1).
///
/// An absent bound stays `null` — "unbounded on that side" is coverage, not a guess.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ChartPeriod {
    /// Inclusive start, or `null` for "unbounded from the beginning".
    #[serde(default)]
    pub start: Option<String>,
    /// Inclusive end, or `null` for "still open".
    #[serde(default)]
    pub end: Option<String>,
}

/// The ordered daily series, in the document's own serde shape (`data-model.md` §4).
///
/// A mirror of WP02's [`BurndownSeries`]: the scope, mode, unit, points, findings and
/// formula version. The aggregate coverage tally is the **document's** (`data-model.md`
/// §6), taken from the shared [`BurndownSeries::coverage`] accessor, so it is not
/// repeated here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChartSeries {
    /// The selected cycle/milestone, its period and its snapshot binding.
    pub scope: ChartScope,
    /// How the series counts.
    pub mode: Mode,
    /// The unit the values are counted in, `null` in count mode (an issue count is
    /// unitless).
    #[serde(default)]
    pub unit: Option<String>,
    /// The ordered daily points, in calendar order, gaps included.
    #[serde(default)]
    pub points: Vec<DailyPoint>,
    /// The findings, in the series' deterministic order.
    #[serde(default)]
    pub diagnostics: Vec<AnalyticsDiagnostic>,
    /// The documented formula this series was folded under.
    pub formula_version: String,
}

impl ChartSeries {
    /// Project a shared [`BurndownSeries`] into the document's shape.
    ///
    /// A pure field copy: the points are the fold's own ordered days, the diagnostics are
    /// the fold's own sorted findings, and the unit is `null` exactly when the series
    /// says it is unitless. Nothing is recomputed and nothing is added.
    #[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(),
        }
    }
}

/// The optional read-only end-date forecast, in the document's own serde shape
/// (`data-model.md` §5).
///
/// A mirror of WP03's [`Projection`], which carries an [`AnalyticsScope`] and therefore
/// no serde derive. `remaining` and `observed_rate` are in the series' own unit, which
/// `mode`/`unit` carry so a reader never sees a rate without the unit it is expressed
/// in; a value the series could not answer is `null`, and `status` says what the
/// forecast could do.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChartProjection {
    /// The selected cycle/milestone, period and snapshot binding.
    pub scope: ChartScope,
    /// How the series the projection was drawn from counts.
    pub mode: Mode,
    /// The unit the remaining work and the rate are expressed in, `null` in count mode.
    #[serde(default)]
    pub unit: Option<String>,
    /// The forecast origin — the caller's day, never a clock.
    pub as_of: String,
    /// The planning end date the forecast is drawn against.
    pub end_date: String,
    /// The remaining work the series carried to `as_of`, in the series' unit.
    #[serde(default)]
    pub remaining: Option<i64>,
    /// The observed completion rate, in the series' unit per calendar day.
    ///
    /// The document's only fractional quantity: every level and delta is whole (see
    /// [`precision_of`]), while this quotient is not. It is the shared projection's own
    /// value, copied — never re-computed here.
    #[serde(default)]
    pub observed_rate: Option<f64>,
    /// The forecast completion date, when one follows.
    #[serde(default)]
    pub projected_completion: Option<String>,
    /// Whether the projected completion falls on or before `end_date`; `null` when no
    /// date was projected.
    #[serde(default)]
    pub meets_end_date: Option<bool>,
    /// Whether the projection could answer, and with what.
    pub status: ProjectionStatus,
    /// The documented formula this projection was drawn under.
    pub formula_version: String,
    /// Why no date was projected, when one was not.
    #[serde(default)]
    pub diagnostics: Vec<AnalyticsDiagnostic>,
}

impl ChartProjection {
    /// Project a shared [`Projection`] into the document's shape.
    ///
    /// A pure field copy: the status, the dates, the remaining work and the rate are the
    /// projection's own values, and its findings travel verbatim. Nothing is re-derived,
    /// so a chart document cannot disagree with `tix analytics project`.
    #[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(),
        }
    }
}

/// One **document-level** finding, in the chart document's own vocabulary (T021/T022).
///
/// The series' and the projection's own findings are the shared
/// [`AnalyticsDiagnostic`] values and travel inside their nodes; this shape carries what
/// only the *export* can know — that a configured remote target cannot carry chart data,
/// and that the document's schema version differs from this build's. `kind` is a plain
/// string so a kind a newer build adds survives a round trip instead of failing to parse.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ChartDiagnostic {
    /// The stable contract name ([`UNSUPPORTED_REMOTE_FIELD`], [`VERSION_MISMATCH`], …).
    pub kind: String,
    /// The stable id of the scope the finding is about, when it is about one.
    #[serde(default)]
    pub scope: Option<String>,
    /// The remote target, when the finding is about a mapping a target cannot carry.
    #[serde(default)]
    pub target: Option<String>,
    /// What is wrong, in one sentence.
    pub reason: String,
    /// What the reader can do about it, when there is something.
    #[serde(default)]
    pub remedy: Option<String>,
}

impl ChartDiagnostic {
    /// A document-level finding about a remote target that cannot carry chart data.
    ///
    /// The day counts are the shared [`CoverageSummary`]'s own — nothing is summed here
    /// — so the sentence names real numbers and the reader can see how much stays local.
    /// It is **not** a mapping: no remote id is invented, no value is converted, and
    /// nothing is sent anywhere.
    #[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),
            ),
        }
    }

    /// A document-level finding about a schema version this build does not write.
    #[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(),
            ),
        }
    }

    /// The stable contract name.
    #[must_use]
    pub fn kind(&self) -> &str {
        &self.kind
    }

    /// One line, `kind [target]: reason` — what a surface prints.
    ///
    /// A remote finding renders with its target in brackets so the rendered line names
    /// the tracker and the promise that the values stay local; every other finding
    /// renders as `kind: reason`.
    #[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),
        }
    }

    /// Whether two findings describe the same problem, which is how a derived finding
    /// is merged rather than appended twice on a repeated round trip.
    #[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
    }
}

// ---------------------------------------------------------------------------
// T019–T022 — the document
// ---------------------------------------------------------------------------

/// Everything a local chart surface knows, in one versioned document (`data-model.md`
/// §6).
///
/// The scope, mode, unit, formula version and coverage are the document's own top-level
/// metadata — required on every export (NFR-002), so a reader never has to descend into
/// the series to learn the period or the unit — and they are built from the **same**
/// source as the series' own fields, so the two cannot disagree.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChartExport {
    /// The format marker: [`FORMAT`].
    pub format: String,
    /// The schema version: [`SCHEMA_VERSION`].
    pub schema_version: u32,
    /// The scope this document describes: its kind, id, period and snapshot binding.
    pub scope: ChartScope,
    /// How the series counts.
    pub mode: Mode,
    /// The unit the values are counted in; `null` in count mode.
    #[serde(default)]
    pub unit: Option<String>,
    /// The precision every series value is stated in ([`precision_of`]).
    pub precision: String,
    /// The ordered daily series.
    pub series: ChartSeries,
    /// The end-date forecast, when the caller asked for one.
    #[serde(default)]
    pub projection: Option<ChartProjection>,
    /// The aggregate coverage tally of the series' days.
    pub coverage: CoverageSummary,
    /// The documented formula the series was folded under.
    pub formula_version: String,
    /// The document-level findings (remote targets, version).
    #[serde(default)]
    pub diagnostics: Vec<ChartDiagnostic>,
    /// Data this build does not interpret, in a namespace of its own.
    ///
    /// Private, so the only public write path is [`Self::insert_extension`], which
    /// cannot produce a document this build cannot read: the key lands in its own
    /// object and can never repeat — or be read in place of — a projected field.
    #[serde(default)]
    extensions: BTreeMap<String, serde_json::Value>,
    /// Keys a newer writer placed directly on the document, preserved verbatim.
    ///
    /// The **read-only** half of the preserved-data story (see the module docs), and the
    /// reason the version finding's promise holds: a `tix-chart` document of another
    /// schema version can add a document-level field this build does not project, and it
    /// is re-emitted where it was read instead of silently dropped.
    ///
    /// `build` always starts it empty and there is no write side, so this build never
    /// emits a key it also projects — the emitted document is always one this build
    /// reads back, unchanged. Read through [`Self::unknown`].
    #[serde(flatten)]
    unknown: BTreeMap<String, serde_json::Value>,
}

/// The marker-only prefix of a document, read before the document itself.
///
/// A payload that is not a chart document is refused **by name** rather than by whatever
/// shape error its fields happened to raise against this one's.
#[derive(Deserialize)]
struct Marker {
    format: String,
}

impl ChartExport {
    /// Build the document for one folded series, with the findings the export adds.
    ///
    /// `series` is the one series shape every surface folds (C-008), so a document's
    /// numbers cannot become a second reading of the repository. `projection` is the
    /// shared projection the caller drew from the **same** series, when the caller asked
    /// for a forecast. `remote_targets` are the configured remote trackers
    /// (`gitlab`/`linear`) that cannot carry chart data: one
    /// [`UNSUPPORTED_REMOTE_FIELD`] finding each, naming the target and the day counts
    /// that stay local.
    #[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(),
        }
    }

    /// Whether this document carries no day at all.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.series.points.is_empty()
    }

    /// The document's format marker.
    #[must_use]
    pub fn format(&self) -> &str {
        &self.format
    }

    /// The document's schema version.
    #[must_use]
    pub const fn schema_version(&self) -> u32 {
        self.schema_version
    }

    /// Preserve one document-level key, in the nested `extensions` object.
    ///
    /// The **only** public write path into the bag, and it cannot produce a document
    /// this build cannot read: the key lands in a namespace of its own, so it can never
    /// duplicate — or be read in place of — a document field.
    pub fn insert_extension(&mut self, key: impl Into<String>, value: serde_json::Value) {
        self.extensions.insert(key.into(), value);
    }

    /// The preserved keys this build does not interpret, in canonical (sorted) order.
    pub fn extensions(&self) -> impl Iterator<Item = (&str, &serde_json::Value)> {
        self.extensions
            .iter()
            .map(|(key, value)| (key.as_str(), value))
    }

    /// The keys a newer writer placed directly on the document, preserved verbatim.
    ///
    /// The read side of the document's catch-all channel: empty for a document this build
    /// built, and holding whatever a foreign writer's document carried at its own top
    /// level otherwise. There is no write side, and the bag is private, which is what
    /// makes a document-level key naming a projected field impossible to produce through
    /// this API.
    #[must_use]
    pub fn unknown(&self) -> &BTreeMap<String, serde_json::Value> {
        &self.unknown
    }

    /// Serialize the document as compact JSON.
    pub fn to_json(&self) -> Result<String> {
        serde_json::to_string(self).map_err(|error| {
            TixError::Parse(format!("chart document cannot be serialized: {error}"))
        })
    }

    /// Serialize the document as pretty JSON — the export surface's shape.
    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}"))
        })
    }

    /// Read a document back.
    ///
    /// The [`Marker`] is read **first**: a document that is not a chart document is
    /// refused by name (its format marker) rather than by whatever shape error its fields
    /// happened to raise — a `tix-capacity`, `tix-time` or `tix-roadmap` payload overlaps
    /// structurally and would otherwise produce a misleading field error. A document of
    /// another *version* is read, its own fields kept — every document-level key this
    /// build does not project travels in [`Self::unknown`] and is re-emitted verbatim —
    /// and the mismatch is merged into the diagnostics once, the checked-and-reported
    /// third answer between refusing a newer writer and silently dropping its fields.
    ///
    /// This build never writes a projected key twice — preserved keys live in their own
    /// nested object, and the document-level catch-all is empty on every path that builds
    /// a document — so its own output always round-trips: the emitted bytes re-read as a
    /// byte fixpoint.
    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)
    }
}