Skip to main content

tix/capacity/
portable.rs

1//! The local portable capacity document (mission `cycle-capacity-planning-01M3C85C`,
2//! WP08, T040–T044; C-001, C-007, C-009, FR-001–FR-005, NFR-003).
3//!
4//! [`PortableCapacityDocument`] is the **local, loss-aware interchange form** of the
5//! capacity surfaces: a versioned JSON document carrying every configured scope with its
6//! configuration, the shared [`CapacityCalculation`] and its findings, every closed
7//! cycle's immutable [`CapacitySnapshot`] evidence and its close/reopen/edit `## History`
8//! summaries, and the remote-target diagnostics that say what a remote tracker cannot
9//! carry. It is a *projection* of the shared types — never a second model, never a second
10//! formula: every number is a field the DTO held, every identity is the library's
11//! resolution, and no arithmetic happens here at all.
12//!
13//! # The approved interchange contracts, and what they fix
14//!
15//! Issues 191/192 (the approved local interchange contracts) fix the *properties* this
16//! format must have, not its field names: a versioned machine-readable schema, a round
17//! trip that **preserves every supported field without loss**, explicit item-level
18//! diagnostics instead of a silent drop, and no external CLI or hosted service required.
19//! The field set is therefore the shared DTO's own field set, in the house shape the
20//! sibling portable documents already use ([`crate::health::portable`],
21//! [`crate::roadmap::portable`], [`crate::views::portable`]: a `format` marker, a
22//! `version`, ordered lists, a `diagnostics` vocabulary, and a preserved-extensions bag).
23//!
24//! # One DTO, no private formula (C-007/NFR-003)
25//!
26//! Every value in this document is read from a shared value: the configuration from
27//! [`crate::capacity::config::CapacityEntry`], the numbers from
28//! [`CapacityCalculation`] (the same value `tix capacity calculate`, the TUI and MCP
29//! consume), and the history from the closed-cycle evidence WP04 writes. There is no
30//! formula, no rounding, no unit fold and no status comparison in this module — a number
31//! that is not a field of one of those values cannot be printed here. That is what makes
32//! the parity obligation structural: `tix capacity calculate` and
33//! `tix export --target capacity` cannot disagree, because there is no second
34//! implementation to disagree with.
35//!
36//! # An absent value is absent, never zero
37//!
38//! Every derived field is `Option`: it is serialized as an explicit JSON `null`
39//! (deliberately not omitted, so the schema is self-describing) and read back as `None`.
40//! `0` is only ever a value that was counted and is zero — `state` says which of the two
41//! states applies ([`CapacityState::Unconfigured`] from the shared calculation), so
42//! "no ceiling is configured" is an explicit state and never a blank or a zero. The
43//! CLI's `-` convention is a *text* rendering; JSON's `null` is the same statement.
44//!
45//! # Two namespaces, so a preserved key can never shadow a value
46//!
47//! Three nodes carry preserved data — the document, a configured scope, and a snapshot
48//! payload — and each one keeps it in **two separate channels** rather than one shared
49//! namespace:
50//!
51//! * `extensions` — a real nested object ([`PortableExtensions`]). This is the channel
52//!   this build *writes*: the `[capacity.<id>]` keys it does not interpret, the snapshot
53//!   `measurements` keys it does not interpret, and whatever a caller adds through
54//!   [`PortableCapacityDocument::insert_extension`]. Because it is its own object, a key
55//!   named `calculation`, `capacity` or `scope` lands *inside* it and can never duplicate
56//!   or replace the projected field beside it — for this reader **or for a lenient one**
57//!   (a last-key-wins JSON parser would otherwise read metadata where a value belongs).
58//! * the flattened catch-all ([`PortableExtensions`] again, field `unknown`, never a wire
59//!   key) — the channel this build *only reads*. serde delivers a key a newer build put
60//!   directly on the node here, and it is written back verbatim; this build never writes
61//!   to it, so it cannot collide with anything. Dropping it would be the silent loss
62//!   T040 forbids.
63//!
64//! A reader therefore cannot make this build emit a duplicate key, and this build cannot
65//! fail to re-read its own output. [`PortableScope`], [`PortableSource`],
66//! [`PortableCalculation`], [`PortableCapacityDiagnostic`] and
67//! [`PortableCapacityHistoryEvent`] are pure projections of shared values with no
68//! extension channel at all, deliberately: there is nothing a writer could have preserved
69//! in them.
70//!
71//! # Preserved values are projected faithfully, or diagnosed
72//!
73//! The source metadata is TOML (`tix.toml`) and YAML (a snapshot payload), and both are
74//! converted **by hand** rather than through `serde_json`'s serializer, because that
75//! serializer is not faithful for three shapes: a `toml` date would arrive as the crate's
76//! private `$__toml_private_datetime` marker object (unreadable to any other tool), a
77//! non-finite float (`.nan`, `.inf`) has no JSON number, and a YAML tag or a non-string
78//! mapping key has no JSON form. A date is projected as its RFC 3339 **string** — the
79//! value every consumer can read. A value that genuinely cannot be represented is
80//! **withheld and reported** as a [`METADATA_NOT_CARRIED`] diagnostic; it is never silently
81//! turned into `null`.
82//!
83//! Withholding is **per top-level key**: a preserved table containing one unrepresentable
84//! leaf is withheld *whole* rather than partially projected, and the diagnostic names that
85//! top-level key and the offending value — not a path into it. "Preserved verbatim" is
86//! therefore a claim about the keys a document carries, not a promise that a partially
87//! representable value survives in part.
88//!
89//! # The version marker: read first, checked, and a mismatch is a diagnostic
90//!
91//! The document carries a [`FORMAT`] marker and a [`VERSION`]. The marker is read
92//! **before** the document is deserialized, so a document that is not a capacity document
93//! at all (a `tix-health`, `tix-roadmap` or `tix.saved-views` payload, whose fields
94//! overlap structurally) is **refused by name** rather than by whatever shape error its
95//! fields happened to raise. A document of a *different version* is read — its own fields
96//! and its preserved keys are kept — and the mismatch is reported as a
97//! [`VERSION_MISMATCH`] diagnostic. Refusing it would make forward compatibility
98//! impossible, and dropping the newer fields is exactly what the interchange contract
99//! forbids; the diagnostic is the honest third answer, and it is merged (never appended
100//! twice), so the document stabilizes after one write instead of growing on every read.
101//!
102//! One shape narrowed inside `version = 1`: [`PortableCapacitySnapshot::breakdown`] is a
103//! nested bag (`Option<PortableExtensions>`), not a bare JSON value, so its contents get
104//! the same faithful projection as the rest. A non-object breakdown in a hand-written
105//! document is now refused by the reader. No document this build can write is affected —
106//! the writer only ever produced an object (`CapacitySnapshot::breakdown` is a map) — and
107//! the format is not released, so the version stays [`VERSION`].
108//!
109//! # Remote namespaces are separate (T043, FR-005)
110//!
111//! `tix export --target gitlab|linear` mirrors **issues** to a remote tracker
112//! ([`crate::sync`], `[export.*]`): it writes remote ids back into issue files and shells
113//! out to `glab`/`linear-cli`. This document is a *local* interchange form and never
114//! reaches either tool. Where a remote target is configured, the target's inability to
115//! carry capacity is **diagnosed explicitly** — `unsupported_remote_field`, naming the
116//! target and the field, stating that the values stay local — because a silent drop and an
117//! invented mapping are both worse than a refusal. The remote export path itself is
118//! untouched: `capacity` is dispatched by name before `Tool::from_target` is consulted.
119//!
120//! # Read-only, on every path
121//!
122//! [`PortableCapacityDocument::build`] and its narrowed sibling
123//! [`PortableCapacityDocument::build_for_cycle`] only read: the issue snapshot and index
124//! the caller already built, the immutable snapshot store, and the cycle records' bodies.
125//! Nothing here opens a file for writing, saves a configuration, appends a `History`
126//! event, or rewrites a snapshot — and an overflow remains a report in this document
127//! exactly as it is on every other surface (C-006).
128#![allow(clippy::module_name_repetitions)]
129
130use std::path::Path;
131
132use serde::{Deserialize, Serialize};
133
134use crate::capacity::calculate::{CapacityCalculation, CapacityScope, CapacitySource};
135use crate::capacity::config::CapacityScopeKind;
136use crate::capacity::resolve_cycle;
137use crate::capacity::scope::IssueSnapshot;
138use crate::capacity::snapshot::CapacitySnapshot;
139use crate::capacity::warning::CapacityWarning;
140use crate::config::Config;
141use crate::cycle_schedule::identity::{CycleReference, GeneratedCycleId, ScheduleIndex};
142use crate::cycle_schedule::snapshot::{CycleSnapshotMetadata, CycleSnapshotStore};
143use crate::cycle_store::{CycleRecord, CycleStore};
144use crate::error::{Result, TixError};
145use crate::model::{History, HistoryEventKind};
146use crate::records::RecordStore;
147
148/// The document's format marker. A consumer reads this before treating the payload as a
149/// capacity document; a document with another marker is refused by name.
150pub const FORMAT: &str = "tix-capacity";
151
152/// The document's format version. Bumped when the shape changes incompatibly.
153pub const VERSION: u32 = 1;
154
155/// The stable kind spelling of a remote-mapping diagnostic.
156///
157/// The same spelling the health, roadmap and views portable documents use for the same
158/// finding, so a reader who learned one vocabulary reads the others.
159pub const UNSUPPORTED_REMOTE_FIELD: &str = "unsupported_remote_field";
160
161/// The stable kind spelling of an "unknown metadata preserved" finding.
162pub const UNKNOWN_METADATA: &str = "unknown_metadata";
163
164/// The stable kind spelling of a "metadata could not be represented" finding.
165pub const METADATA_NOT_CARRIED: &str = "metadata_not_carried";
166
167/// The stable kind spelling of a "different document version" finding.
168pub const VERSION_MISMATCH: &str = "version_mismatch";
169
170// ---------------------------------------------------------------------------
171// The preserved-metadata bags
172// ---------------------------------------------------------------------------
173
174/// A bag of preserved `(key, value)` pairs, in the document's own vocabulary.
175///
176/// Used for both channels (see the module docs): the nested `extensions` object this
177/// build writes, and the flattened catch-all it only reads. A newtype over a JSON value
178/// map, rather than a bare `Map`, so the one inserter is private and every write goes
179/// through [`PortableCapacityDocument::insert_extension`]. A JSON map (not `toml::Table`)
180/// because TOML has no null: a newer build writing `"future": null` must survive a round
181/// trip through this one, and a TOML value cannot hold a null at all. The map is sorted
182/// (`serde_json`'s `preserve_order` feature is off), so key *order* is normalised while the
183/// values — the thing a round trip preserves — are kept exactly.
184#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
185#[serde(transparent)]
186pub struct PortableExtensions(serde_json::Map<String, serde_json::Value>);
187
188impl PortableExtensions {
189    /// An empty bag.
190    #[must_use]
191    pub fn new() -> Self {
192        Self(serde_json::Map::new())
193    }
194
195    /// Whether no metadata is present.
196    #[must_use]
197    pub fn is_empty(&self) -> bool {
198        self.0.is_empty()
199    }
200
201    /// The number of preserved keys.
202    #[must_use]
203    pub fn len(&self) -> usize {
204        self.0.len()
205    }
206
207    /// Look up a preserved key.
208    #[must_use]
209    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
210        self.0.get(key)
211    }
212
213    /// The preserved keys, in canonical (sorted) order.
214    pub fn keys(&self) -> impl Iterator<Item = &str> {
215        self.0.keys().map(String::as_str)
216    }
217
218    /// The underlying map, for a caller that must inspect it whole.
219    #[must_use]
220    pub fn as_map(&self) -> &serde_json::Map<String, serde_json::Value> {
221        &self.0
222    }
223
224    /// Preserve one `(key, value)` pair, returning the previous value if any.
225    ///
226    /// Deliberately **private**: a preserved key is only safe inside a bag of its own (the
227    /// nested `extensions` object), so the only public way in is the checked
228    /// [`PortableCapacityDocument::insert_extension`]. A public raw inserter would let a
229    /// caller write a key a document field already owns and make this build refuse its own
230    /// output.
231    fn insert(
232        &mut self,
233        key: impl Into<String>,
234        value: serde_json::Value,
235    ) -> Option<serde_json::Value> {
236        self.0.insert(key.into(), value)
237    }
238
239    /// Carry a `[capacity.<id>]` entry's unknown keys (`CapacityEntry::extensions`).
240    ///
241    /// Returns the bag and the keys that had to be **withheld** because JSON cannot
242    /// represent their value faithfully (see [`toml_to_json`]).
243    fn from_toml(table: &toml::Table) -> (Self, Vec<NotCarried>) {
244        let mut bag = Self::new();
245        let mut not_carried = Vec::new();
246        for (key, value) in table {
247            match toml_to_json(value) {
248                Ok(json) => {
249                    bag.insert(key.clone(), json);
250                }
251                Err(reason) => not_carried.push(NotCarried {
252                    key: key.clone(),
253                    reason,
254                }),
255            }
256        }
257        (bag, not_carried)
258    }
259
260    /// Carry a snapshot payload's unknown keys (`CapacitySnapshot::extensions`).
261    fn from_yaml(metadata: &CycleSnapshotMetadata) -> (Self, Vec<NotCarried>) {
262        let mut bag = Self::new();
263        let mut not_carried = Vec::new();
264        for (key, value) in metadata {
265            match yaml_to_json(value) {
266                Ok(json) => {
267                    bag.insert(key.clone(), json);
268                }
269                Err(reason) => not_carried.push(NotCarried {
270                    key: key.clone(),
271                    reason,
272                }),
273            }
274        }
275        (bag, not_carried)
276    }
277}
278
279/// One preserved key whose value could not be projected, and why.
280#[derive(Debug, Clone)]
281struct NotCarried {
282    /// The metadata key.
283    key: String,
284    /// Why JSON cannot represent it faithfully.
285    reason: String,
286}
287
288/// Project one `toml` value into JSON, or say why it cannot be carried.
289///
290/// Hand-written rather than `serde_json::to_value`: the `toml` crate's own serializer
291/// renders a [`toml::Value::Datetime`] as its private `$__toml_private_datetime` marker
292/// object, which a consumer outside `toml` cannot read as a date. A date is projected as
293/// its RFC 3339 string instead, and a non-finite float (which has no JSON number) is an
294/// error the caller reports rather than a silent `null`.
295fn toml_to_json(value: &toml::Value) -> std::result::Result<serde_json::Value, String> {
296    Ok(match value {
297        toml::Value::String(text) => serde_json::Value::String(text.clone()),
298        toml::Value::Integer(number) => serde_json::Value::from(*number),
299        toml::Value::Float(number) => {
300            let Some(number) = serde_json::Number::from_f64(*number) else {
301                return Err(format!(
302                    "the float `{number}` is not finite, and JSON has no number for it"
303                ));
304            };
305            serde_json::Value::Number(number)
306        }
307        toml::Value::Boolean(flag) => serde_json::Value::Bool(*flag),
308        toml::Value::Datetime(datetime) => serde_json::Value::String(datetime.to_string()),
309        toml::Value::Array(items) => {
310            let mut array = Vec::with_capacity(items.len());
311            for item in items {
312                array.push(toml_to_json(item)?);
313            }
314            serde_json::Value::Array(array)
315        }
316        toml::Value::Table(table) => {
317            let mut map = serde_json::Map::new();
318            for (key, item) in table {
319                map.insert(key.clone(), toml_to_json(item)?);
320            }
321            serde_json::Value::Object(map)
322        }
323    })
324}
325
326/// Project one `serde_yaml` value into JSON, or say why it cannot be carried.
327///
328/// The YAML bridge has the same three gaps as the TOML one: a non-finite number, a
329/// non-string mapping key, and a tag have no JSON form. Null *is* representable and is
330/// kept as JSON `null`.
331fn yaml_to_json(value: &serde_yaml::Value) -> std::result::Result<serde_json::Value, String> {
332    Ok(match value {
333        serde_yaml::Value::Null => serde_json::Value::Null,
334        serde_yaml::Value::Bool(flag) => serde_json::Value::Bool(*flag),
335        serde_yaml::Value::Number(number) => {
336            if let Some(signed) = number.as_i64() {
337                serde_json::Value::from(signed)
338            } else if let Some(unsigned) = number.as_u64() {
339                serde_json::Value::from(unsigned)
340            } else {
341                let Some(float) = number.as_f64() else {
342                    return Err(format!("the number `{number:?}` has no JSON form"));
343                };
344                let Some(number) = serde_json::Number::from_f64(float) else {
345                    return Err(format!(
346                        "the number `{float}` is not finite, and JSON has no number for it"
347                    ));
348                };
349                serde_json::Value::Number(number)
350            }
351        }
352        serde_yaml::Value::String(text) => serde_json::Value::String(text.clone()),
353        serde_yaml::Value::Sequence(items) => {
354            let mut array = Vec::with_capacity(items.len());
355            for item in items {
356                array.push(yaml_to_json(item)?);
357            }
358            serde_json::Value::Array(array)
359        }
360        serde_yaml::Value::Mapping(mapping) => {
361            let mut map = serde_json::Map::new();
362            for (key, item) in mapping {
363                let serde_yaml::Value::String(key) = key else {
364                    return Err(
365                        "a mapping key is not a string, and JSON cannot carry one".to_string()
366                    );
367                };
368                map.insert(key.clone(), yaml_to_json(item)?);
369            }
370            serde_json::Value::Object(map)
371        }
372        serde_yaml::Value::Tagged(tagged) => {
373            return Err(format!(
374                "the value carries the YAML tag `{}`, which JSON cannot carry",
375                tagged.tag
376            ));
377        }
378    })
379}
380
381// ---------------------------------------------------------------------------
382// T040/T041 — the projected shared values
383// ---------------------------------------------------------------------------
384
385/// The resolved scope one document row describes, projected from [`CapacityScope`].
386///
387/// All three halves are carried: `reference` is what the configuration wrote, `display` is
388/// what it resolves to, and `kind` is the dimension. Keeping both spellings is what lets a
389/// reader see that a generated cycle's stable id and its name are **one** scope (C-003) —
390/// the parity property earlier reviews made a blocker.
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392pub struct PortableScope {
393    /// The dimension (`cycle`/`team`/`assignee`).
394    pub kind: String,
395    /// The reference as configured.
396    pub reference: String,
397    /// The reference as it resolves (a generated cycle's name, an assignee's handle).
398    pub display: String,
399}
400
401impl PortableScope {
402    /// Project the shared scope value.
403    #[must_use]
404    pub fn from_scope(scope: &CapacityScope) -> Self {
405        Self {
406            kind: scope.kind.as_str().to_string(),
407            reference: scope.reference.clone(),
408            display: scope.display.clone(),
409        }
410    }
411
412    /// The scope as one message reads it: `cycle \`Cycle 1\``.
413    #[must_use]
414    pub fn describe(&self) -> String {
415        format!("{} `{}`", self.kind, self.display)
416    }
417}
418
419/// Which snapshot reading the numbers came from, projected from [`CapacitySource`].
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421pub struct PortableSource {
422    /// How many issues the snapshot indexed.
423    pub issues: usize,
424    /// The units the snapshot resolved conversions for.
425    #[serde(default)]
426    pub units: Vec<String>,
427}
428
429impl PortableSource {
430    /// Project the shared source value.
431    #[must_use]
432    fn from_source(source: &CapacitySource) -> Self {
433        Self {
434            issues: source.issues,
435            units: source.units.clone(),
436        }
437    }
438}
439
440/// One finding, in the document's vocabulary.
441///
442/// Used for **both** halves of the document: a [`CapacityWarning`] the shared calculation
443/// raised (kind, scope, issues, the numbers it compares, its reason and its remedy) and a
444/// document-level finding (preserved metadata, an unsupported remote field, a version
445/// mismatch). One shape means a reader learns one vocabulary, the way the health and
446/// roadmap documents do.
447#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
448pub struct PortableCapacityDiagnostic {
449    /// The stable contract name (`capacity_overflow`, `missing_conversion`,
450    /// `unknown_metadata`, `metadata_not_carried`, `unsupported_remote_field`,
451    /// `version_mismatch`, …).
452    ///
453    /// A plain string, so a kind a newer build adds survives a round trip as a string
454    /// instead of failing to parse.
455    pub kind: String,
456    /// The scope the finding is about, when it is about one.
457    #[serde(default)]
458    pub scope: Option<PortableScope>,
459    /// The issues to look at, in issue order.
460    #[serde(default)]
461    pub issues: Vec<u32>,
462    /// The committed total the finding compares, when it compares one.
463    #[serde(default)]
464    pub current: Option<u64>,
465    /// The configured ceiling, when the finding is about a ceiling.
466    #[serde(default)]
467    pub limit: Option<u64>,
468    /// How far over the ceiling `current` is, when the finding is an overflow.
469    #[serde(default)]
470    pub overflow: Option<u64>,
471    /// The unit the numbers are counted in, when the finding is about a count.
472    #[serde(default)]
473    pub unit: Option<String>,
474    /// The record-relative field the problem sits at, when it is field-shaped
475    /// (`capacity.zulu`, `capacity.zulu.future_float`, `snapshots.sprint:000001`).
476    #[serde(default)]
477    pub field: Option<String>,
478    /// The remote target, when the finding is about a mapping a target cannot carry.
479    #[serde(default)]
480    pub target: Option<String>,
481    /// What is wrong, in one sentence.
482    pub reason: String,
483    /// What the reader can do about it, when there is something.
484    #[serde(default)]
485    pub remedy: Option<String>,
486}
487
488impl PortableCapacityDiagnostic {
489    /// Project one shared [`CapacityWarning`] into the wire vocabulary.
490    ///
491    /// A pure field copy: the kind spelling is the registry's own (`as_str`), the numbers
492    /// are the warning's own, and the reason/remedy are the sentence pair the warning
493    /// already carries. Nothing is recomputed and nothing is added.
494    #[must_use]
495    pub fn from_warning(warning: &CapacityWarning) -> Self {
496        Self {
497            kind: warning.kind.as_str().to_string(),
498            scope: Some(PortableScope::from_scope(&warning.scope)),
499            issues: warning.issues.clone(),
500            current: warning.current,
501            limit: warning.limit,
502            overflow: warning.overflow,
503            unit: warning.unit.clone(),
504            field: None,
505            target: None,
506            reason: warning.reason.clone(),
507            remedy: warning.remedy.clone(),
508        }
509    }
510
511    /// A document-level finding about metadata this build preserved.
512    fn unknown_metadata(field: String, scope: PortableScope, keys: Vec<String>) -> Self {
513        Self {
514            kind: UNKNOWN_METADATA.to_string(),
515            scope: Some(scope),
516            issues: Vec::new(),
517            current: None,
518            limit: None,
519            overflow: None,
520            unit: None,
521            field: Some(field),
522            target: None,
523            reason: format!(
524                "{} key(s) this build does not interpret are preserved verbatim under \
525                 `extensions`: {}",
526                keys.len(),
527                keys.join(", ")
528            ),
529            remedy: Some(
530                "keep the metadata: a build that understands the key reads it back unchanged"
531                    .to_string(),
532            ),
533        }
534    }
535
536    /// A document-level finding about a preserved key that could not be carried.
537    fn metadata_not_carried(field: String, scope: PortableScope, not_carried: &NotCarried) -> Self {
538        Self {
539            kind: METADATA_NOT_CARRIED.to_string(),
540            scope: Some(scope),
541            issues: Vec::new(),
542            current: None,
543            limit: None,
544            overflow: None,
545            unit: None,
546            field: Some(format!("{field}.{}", not_carried.key)),
547            target: None,
548            reason: format!(
549                "`{key}` is not carried: {reason}",
550                key = not_carried.key,
551                reason = not_carried.reason
552            ),
553            remedy: Some(
554                "rewrite the value in a form JSON can represent (a finite number, a date as an \
555                 RFC 3339 string), or remove the key"
556                    .to_string(),
557            ),
558        }
559    }
560
561    /// A document-level finding about a remote target that cannot carry capacity.
562    fn unsupported_remote(target: &str, scopes: usize, snapshots: usize) -> Self {
563        Self {
564            kind: UNSUPPORTED_REMOTE_FIELD.to_string(),
565            scope: None,
566            issues: Vec::new(),
567            current: None,
568            limit: None,
569            overflow: None,
570            unit: None,
571            field: Some("capacity".to_string()),
572            target: Some(target.to_string()),
573            reason: format!(
574                "`{target}` has no capacity field, so the {scopes} configured scope(s) and \
575                 {snapshots} closed-cycle snapshot(s) in this document stay local; nothing is \
576                 mapped and nothing is dropped"
577            ),
578            remedy: Some(format!(
579                "keep the local document: `tix export --target {target}` mirrors issues, and \
580                 capacity is a local interchange value rather than a remote field"
581            )),
582        }
583    }
584
585    /// A document-level finding about a version this build does not write.
586    fn version_mismatch(found: u32) -> Self {
587        Self {
588            kind: VERSION_MISMATCH.to_string(),
589            scope: None,
590            issues: Vec::new(),
591            current: None,
592            limit: None,
593            overflow: None,
594            unit: None,
595            field: Some("version".to_string()),
596            target: None,
597            reason: format!(
598                "the document declares version {found}, but this build writes version {VERSION}; \
599                 its fields are preserved rather than refused or dropped"
600            ),
601            remedy: Some(
602                "read it with a build that writes that version; this one preserves the fields it \
603                 does not interpret"
604                    .to_string(),
605            ),
606        }
607    }
608
609    /// The stable contract name.
610    #[must_use]
611    pub fn kind(&self) -> &str {
612        &self.kind
613    }
614
615    /// One line, `kind [field]: reason` — what a surface prints.
616    #[must_use]
617    pub fn render(&self) -> String {
618        match self.field.as_deref() {
619            Some(field) => format!("{} [{field}]: {}", self.kind, self.reason),
620            None => format!("{}: {}", self.kind, self.reason),
621        }
622    }
623
624    /// Whether two diagnostics describe the same problem, which is how a derived
625    /// diagnostic is merged rather than appended twice on a repeated round trip.
626    #[must_use]
627    fn same_as(&self, other: &Self) -> bool {
628        self.kind == other.kind
629            && self.field == other.field
630            && self.target == other.target
631            && self.reason == other.reason
632    }
633}
634
635/// The shared calculation, projected field for field (T041).
636///
637/// Every field is the DTO's own value; the `Option`s are the DTO's `Option`s, so an absent
638/// value stays absent and unconfigured stays an explicit state.
639#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
640pub struct PortableCalculation {
641    /// The resolved scope the numbers describe.
642    pub scope: PortableScope,
643    /// Whether the scope had a ceiling it could count against.
644    pub state: String,
645    /// The ceiling in force, or `null` when the scope was unconfigured.
646    #[serde(default)]
647    pub capacity: Option<u64>,
648    /// The unit every number is counted in, or `null` when unconfigured.
649    #[serde(default)]
650    pub unit: Option<String>,
651    /// The sum of the valid, converted estimates in scope.
652    #[serde(default)]
653    pub committed: Option<u64>,
654    /// The committed part the shared completion service calls complete.
655    #[serde(default)]
656    pub completed: Option<u64>,
657    /// `committed - completed`.
658    #[serde(default)]
659    pub remaining: Option<u64>,
660    /// `max(committed - capacity, 0)`.
661    #[serde(default)]
662    pub overflow: Option<u64>,
663    /// `committed` as a floored whole percentage of `capacity`.
664    #[serde(default)]
665    pub utilization: Option<u32>,
666    /// Issues whose estimate was counted into `committed`.
667    #[serde(default)]
668    pub counted_issues: Option<u32>,
669    /// Issues that state an estimate which could not be counted in the unit.
670    #[serde(default)]
671    pub uncounted_issues: Option<u32>,
672    /// Issues that state no estimate at all.
673    #[serde(default)]
674    pub unestimated_issues: Option<u32>,
675    /// The reading the numbers came from.
676    pub source: PortableSource,
677    /// The findings, in the DTO's derivation order.
678    #[serde(default)]
679    pub diagnostics: Vec<PortableCapacityDiagnostic>,
680}
681
682impl PortableCalculation {
683    /// Project one shared [`CapacityCalculation`].
684    #[must_use]
685    fn from_calculation(calculation: &CapacityCalculation) -> Self {
686        Self {
687            scope: PortableScope::from_scope(&calculation.scope),
688            state: calculation.state().as_str().to_string(),
689            capacity: calculation.capacity,
690            unit: calculation.unit.clone(),
691            committed: calculation.committed,
692            completed: calculation.completed,
693            remaining: calculation.remaining,
694            overflow: calculation.overflow,
695            utilization: calculation.utilization,
696            counted_issues: calculation.counted_issues,
697            uncounted_issues: calculation.uncounted_issues,
698            unestimated_issues: calculation.unestimated_issues,
699            source: PortableSource::from_source(&calculation.source),
700            diagnostics: calculation
701                .diagnostics
702                .iter()
703                .map(PortableCapacityDiagnostic::from_warning)
704                .collect(),
705        }
706    }
707}
708
709/// One configured scope: its configuration, the shared calculation, and its preserved
710/// metadata (T040).
711///
712/// The configuration fields are the entry **verbatim** (`capacity` as the `i64` the entry
713/// stores, `unit`, `enabled`), so a disabled entry's retained ceiling is carried rather
714/// than dropped; `effective` is the entry's own answer for whether a ceiling is in force.
715/// The calculation is the shared DTO, and it reports `state = "unconfigured"` for an entry
716/// that is not in force — the explicit state, never a zero.
717///
718/// `extensions` and `unknown` are the two preservation channels the module docs describe:
719/// the entry's unknown keys live in the nested object, so one named `calculation` cannot
720/// shadow the real one.
721#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
722pub struct PortableCapacityScope {
723    /// The `[capacity.<id>]` handle the entry is stored under.
724    pub id: String,
725    /// The scope the entry names.
726    pub scope: PortableScope,
727    /// Whether the entry is enabled.
728    pub enabled: bool,
729    /// Whether a ceiling is in force (`enabled` with a value and a unit).
730    pub effective: bool,
731    /// The ceiling the entry stores, verbatim, or `null` when it stores none.
732    #[serde(default)]
733    pub capacity: Option<i64>,
734    /// The unit the entry stores, verbatim, or `null` when it stores none.
735    #[serde(default)]
736    pub unit: Option<String>,
737    /// The shared calculation for this scope.
738    pub calculation: PortableCalculation,
739    /// The entry's unknown keys, preserved verbatim in a namespace of their own.
740    #[serde(default)]
741    pub extensions: PortableExtensions,
742    /// Keys a newer writer placed directly on this node, preserved verbatim.
743    #[serde(flatten)]
744    pub unknown: PortableExtensions,
745}
746
747/// One closed cycle's immutable capacity evidence, projected from [`CapacitySnapshot`]
748/// (T042).
749///
750/// The identity (`snapshot_id`, `cycle_id`, `closed_at`, `closed_by`) and the data are the
751/// evidence's own values, read through the WP04 reader: nothing is recomputed from the
752/// live schedule or the current configuration, which are exactly what a later edit is
753/// allowed to change. `cycle_name` is the display label the identity index resolves the
754/// stable id to; it is carried *beside* the identity, never as one.
755#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
756pub struct PortableCapacitySnapshot {
757    /// The stable snapshot identity (`<cycle-id>@<close-time>`).
758    pub snapshot_id: String,
759    /// The generated cycle this evidence belongs to, by its stable id.
760    pub cycle_id: String,
761    /// The generated cycle's display label at read time, when the identity resolves.
762    #[serde(default)]
763    pub cycle_name: Option<String>,
764    /// The explicit close time, RFC 3339.
765    pub closed_at: String,
766    /// The audit actor who closed the cycle.
767    pub closed_by: String,
768    /// The scope the calculation ran for.
769    pub scope: PortableScope,
770    /// Whether the scope had a ceiling it could count against at close.
771    pub state: String,
772    /// The ceiling in force at close.
773    #[serde(default)]
774    pub capacity: Option<u64>,
775    /// The unit every number is counted in at close.
776    #[serde(default)]
777    pub unit: Option<String>,
778    /// The committed total at close.
779    #[serde(default)]
780    pub committed: Option<u64>,
781    /// The completed part of it at close.
782    #[serde(default)]
783    pub completed: Option<u64>,
784    /// `committed - completed` at close.
785    #[serde(default)]
786    pub remaining: Option<u64>,
787    /// `max(committed - capacity, 0)` at close.
788    #[serde(default)]
789    pub overflow: Option<u64>,
790    /// The floored utilization percentage at close.
791    #[serde(default)]
792    pub utilization: Option<u32>,
793    /// How many issues were counted into `committed`.
794    #[serde(default)]
795    pub counted_issues: Option<u32>,
796    /// How many issues stated an estimate that could not be counted.
797    #[serde(default)]
798    pub uncounted_issues: Option<u32>,
799    /// How many issues stated no estimate at all.
800    #[serde(default)]
801    pub unestimated_issues: Option<u32>,
802    /// The reading the numbers came from.
803    pub source: PortableSource,
804    /// The findings the close recorded, in derivation order.
805    #[serde(default)]
806    pub diagnostics: Vec<PortableCapacityDiagnostic>,
807    /// The optional team/assignee breakdown, persisted verbatim in its own namespace.
808    #[serde(default)]
809    pub breakdown: Option<PortableExtensions>,
810    /// The payload's unknown `measurements` keys, preserved verbatim.
811    #[serde(default)]
812    pub extensions: PortableExtensions,
813    /// Keys a newer writer placed directly on this node, preserved verbatim.
814    #[serde(flatten)]
815    pub unknown: PortableExtensions,
816}
817
818impl PortableCapacitySnapshot {
819    /// Project one immutable [`CapacitySnapshot`], resolving its display label when the
820    /// index answers for the identity.
821    fn from_snapshot(
822        evidence: &CapacitySnapshot,
823        index: &ScheduleIndex,
824    ) -> Result<(Self, Vec<(String, NotCarried)>)> {
825        let cycle_name = GeneratedCycleId::parse(&evidence.cycle_id)
826            .ok()
827            .and_then(|id| index.cycle(&id))
828            .map(|cycle| cycle.name.clone());
829        let (extensions, mut not_carried) = PortableExtensions::from_yaml(&evidence.extensions);
830        let mut fields = Vec::new();
831        for missing in not_carried.drain(..) {
832            fields.push((String::from("extensions"), missing));
833        }
834        let breakdown = match &evidence.breakdown {
835            Some(metadata) => {
836                let (bag, missing) = PortableExtensions::from_yaml(metadata);
837                for not_carried in missing {
838                    fields.push((String::from("breakdown"), not_carried));
839                }
840                Some(bag)
841            }
842            None => None,
843        };
844        Ok((
845            Self {
846                snapshot_id: evidence.snapshot_id.clone(),
847                cycle_id: evidence.cycle_id.clone(),
848                cycle_name,
849                closed_at: evidence.closed_at.clone(),
850                closed_by: evidence.closed_by.clone(),
851                scope: PortableScope::from_scope(&evidence.scope),
852                state: evidence.state.as_str().to_string(),
853                capacity: evidence.capacity,
854                unit: evidence.unit.clone(),
855                committed: evidence.committed,
856                completed: evidence.completed,
857                remaining: evidence.remaining,
858                overflow: evidence.overflow,
859                utilization: evidence.utilization,
860                counted_issues: evidence.counted_issues,
861                uncounted_issues: evidence.uncounted_issues,
862                unestimated_issues: evidence.unestimated_issues,
863                source: PortableSource::from_source(&evidence.source),
864                diagnostics: evidence
865                    .diagnostics
866                    .iter()
867                    .map(PortableCapacityDiagnostic::from_warning)
868                    .collect(),
869                breakdown,
870                extensions,
871                unknown: PortableExtensions::new(),
872            },
873            fields,
874        ))
875    }
876}
877
878/// One `## History` entry about a cycle's capacity lifecycle (T042).
879///
880/// The close/reopen/edit vocabulary WP04 writes is carried verbatim: the kind, the
881/// timestamp, the actor and the detail line exactly as the append-only log records them.
882/// The entry is **never re-parsed** here, so a detail this build does not understand is
883/// preserved rather than interpreted.
884#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
885pub struct PortableCapacityHistoryEvent {
886    /// The cycle's stable generated id, when it has one; its record name otherwise.
887    pub cycle_id: String,
888    /// The cycle's record name (a mutable display label).
889    pub cycle_name: String,
890    /// The event kind (`cycle_closed` or `cycle_reopened`).
891    pub kind: String,
892    /// When it happened, RFC 3339, as recorded.
893    pub timestamp: String,
894    /// Who did it, as recorded.
895    pub actor: String,
896    /// The detail line, verbatim.
897    pub detail: String,
898}
899
900// ---------------------------------------------------------------------------
901// T040 — the document
902// ---------------------------------------------------------------------------
903
904/// Everything the local capacity surfaces know, in one versioned document.
905#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
906pub struct PortableCapacityDocument {
907    /// The format marker: [`FORMAT`].
908    pub format: String,
909    /// The format version: [`VERSION`].
910    pub version: u32,
911    /// One row per configured scope, in `[capacity]` declaration order.
912    #[serde(default)]
913    pub scopes: Vec<PortableCapacityScope>,
914    /// Every closed cycle's immutable evidence, in snapshot order.
915    #[serde(default)]
916    pub snapshots: Vec<PortableCapacitySnapshot>,
917    /// Every capacity lifecycle event, in cycle order then file (append) order.
918    #[serde(default)]
919    pub history: Vec<PortableCapacityHistoryEvent>,
920    /// Document-level findings (preserved metadata, unsupported remote fields, version).
921    #[serde(default)]
922    pub diagnostics: Vec<PortableCapacityDiagnostic>,
923    /// Document-level keys this build preserved, in a namespace of their own.
924    #[serde(default)]
925    pub extensions: PortableExtensions,
926    /// Keys a newer writer placed directly on the document, preserved verbatim.
927    #[serde(flatten)]
928    pub unknown: PortableExtensions,
929}
930
931/// The marker-only prefix of a document, read before the document itself.
932///
933/// The same shape [`crate::roadmap::portable`] reads first: a payload that is not a
934/// capacity document is refused **by name** rather than by whatever shape error its fields
935/// happened to raise against this one's.
936#[derive(Deserialize)]
937struct Marker {
938    format: String,
939}
940
941/// The one cycle a narrowed document describes.
942struct CycleFilter {
943    /// The stable id, when the reference resolves to a generated cycle.
944    id: Option<String>,
945    /// The resolved name.
946    name: String,
947    /// The label a cycle scope resolves to (the identity the scope matches on).
948    label: String,
949}
950
951impl CycleFilter {
952    /// Resolve `reference` through the library's own adapter.
953    fn resolve(index: &ScheduleIndex, reference: &str) -> Result<Self> {
954        let (id, name) = match resolve_cycle(index, reference) {
955            CycleReference::Generated { id, name, .. } => (Some(id.as_str().to_string()), name),
956            CycleReference::Legacy { name } => (None, name),
957            CycleReference::Unknown { name } => {
958                return Err(TixError::NotFound(format!(
959                    "no cycle `{name}` resolves to a record, so there is no capacity to export \
960                     for it"
961                )));
962            }
963        };
964        let label = CapacityScope::new(CapacityScopeKind::Cycle, reference, index).display;
965        Ok(Self { id, name, label })
966    }
967
968    /// Whether a configured scope is this cycle's.
969    fn keeps_scope(&self, scope: &PortableScope) -> bool {
970        scope.kind == CapacityScopeKind::Cycle.as_str() && scope.display == self.label
971    }
972
973    /// Whether a snapshot belongs to this cycle.
974    fn keeps_snapshot(&self, snapshot: &PortableCapacitySnapshot) -> bool {
975        self.id.as_deref() == Some(snapshot.cycle_id.as_str())
976    }
977
978    /// Whether a history entry belongs to this cycle.
979    fn keeps_history(&self, event: &PortableCapacityHistoryEvent) -> bool {
980        self.id.as_deref() == Some(event.cycle_id.as_str()) || event.cycle_name == self.name
981    }
982}
983
984impl PortableCapacityDocument {
985    /// Build the whole local capacity document: every configured scope with its
986    /// calculation, every closed cycle's evidence, every capacity lifecycle event, and the
987    /// remote-target diagnostics.
988    ///
989    /// `snapshot` is the one-pass issue snapshot the caller already built with `index` (the
990    /// same pair `tix capacity calculate` uses), so a scope's numbers are the shared
991    /// calculation's and a multi-scope export still indexes the issues **once** (NFR-001).
992    /// The read path touches the snapshot store and the cycle records and writes nothing.
993    pub fn build(
994        config: &Config,
995        root: &Path,
996        index: &ScheduleIndex,
997        snapshot: &IssueSnapshot,
998    ) -> Result<Self> {
999        Self::assemble(config, root, index, snapshot, None)
1000    }
1001
1002    /// Build the document for **one** cycle: the scope that configures it, its immutable
1003    /// evidence and its lifecycle history.
1004    ///
1005    /// The reference is what the caller wrote — a generated cycle's name or its stable id —
1006    /// and it is folded through the same adapter every other surface uses, so the two
1007    /// spellings select one document (C-003). A reference nothing resolves to is a
1008    /// `not_found`, not an empty document.
1009    pub fn build_for_cycle(
1010        config: &Config,
1011        root: &Path,
1012        index: &ScheduleIndex,
1013        snapshot: &IssueSnapshot,
1014        reference: &str,
1015    ) -> Result<Self> {
1016        let filter = CycleFilter::resolve(index, reference)?;
1017        Self::assemble(config, root, index, snapshot, Some(filter))
1018    }
1019
1020    /// The one assembly path, optionally narrowed to a cycle.
1021    #[allow(clippy::too_many_lines)]
1022    fn assemble(
1023        config: &Config,
1024        root: &Path,
1025        index: &ScheduleIndex,
1026        snapshot: &IssueSnapshot,
1027        filter: Option<CycleFilter>,
1028    ) -> Result<Self> {
1029        let mut document = Self {
1030            format: FORMAT.to_string(),
1031            version: VERSION,
1032            scopes: Vec::new(),
1033            snapshots: Vec::new(),
1034            history: Vec::new(),
1035            diagnostics: Vec::new(),
1036            extensions: PortableExtensions::new(),
1037            unknown: PortableExtensions::new(),
1038        };
1039
1040        // The configured scopes, in declaration order; the calculation is the shared DTO's.
1041        for (id, entry) in config.capacity.iter() {
1042            let Some(kind) = entry.scope_kind() else {
1043                continue;
1044            };
1045            let reference = entry.scope_id().unwrap_or_default();
1046            let scope = CapacityScope::new(kind, reference, index);
1047            let projected = PortableScope::from_scope(&scope);
1048            if filter
1049                .as_ref()
1050                .is_some_and(|filter| !filter.keeps_scope(&projected))
1051            {
1052                continue;
1053            }
1054            let calculation = CapacityCalculation::for_scope(snapshot, index, config, scope);
1055            let (extensions, not_carried) = PortableExtensions::from_toml(entry.extensions.table());
1056            if !extensions.is_empty() {
1057                document
1058                    .diagnostics
1059                    .push(PortableCapacityDiagnostic::unknown_metadata(
1060                        format!("capacity.{id}"),
1061                        projected.clone(),
1062                        extensions.keys().map(str::to_string).collect(),
1063                    ));
1064            }
1065            for missing in &not_carried {
1066                document
1067                    .diagnostics
1068                    .push(PortableCapacityDiagnostic::metadata_not_carried(
1069                        format!("capacity.{id}"),
1070                        projected.clone(),
1071                        missing,
1072                    ));
1073            }
1074            document.scopes.push(PortableCapacityScope {
1075                id: id.to_string(),
1076                scope: projected,
1077                enabled: entry.enabled,
1078                effective: entry.effective(),
1079                capacity: entry.capacity,
1080                unit: entry.unit.clone(),
1081                calculation: PortableCalculation::from_calculation(&calculation),
1082                extensions,
1083                unknown: PortableExtensions::new(),
1084            });
1085        }
1086
1087        // The immutable evidence, read through the WP04 reader; a snapshot with no capacity
1088        // payload (written before this mission) contributes nothing and is not an error —
1089        // nothing is migrated in place.
1090        let store = CycleSnapshotStore::new(root);
1091        for stored in store.list()? {
1092            let Some(evidence) = CapacitySnapshot::from_cycle_snapshot(&stored)? else {
1093                continue;
1094            };
1095            let (projected, not_carried) =
1096                PortableCapacitySnapshot::from_snapshot(&evidence, index)?;
1097            if filter
1098                .as_ref()
1099                .is_some_and(|filter| !filter.keeps_snapshot(&projected))
1100            {
1101                continue;
1102            }
1103            let field = format!("snapshots.{}", projected.snapshot_id);
1104            if !projected.extensions.is_empty() {
1105                document
1106                    .diagnostics
1107                    .push(PortableCapacityDiagnostic::unknown_metadata(
1108                        field.clone(),
1109                        projected.scope.clone(),
1110                        projected.extensions.keys().map(str::to_string).collect(),
1111                    ));
1112            }
1113            for (channel, missing) in &not_carried {
1114                document
1115                    .diagnostics
1116                    .push(PortableCapacityDiagnostic::metadata_not_carried(
1117                        format!("{field}.{channel}"),
1118                        projected.scope.clone(),
1119                        missing,
1120                    ));
1121            }
1122            document.snapshots.push(projected);
1123        }
1124
1125        // The lifecycle history: the capacity kinds only, verbatim, in file order.
1126        for record in RecordStore::new(CycleStore::new(root).dir()).list::<CycleRecord>()? {
1127            let (history, _) = History::parse_front(&record.body);
1128            let cycle_id = match index.resolve(&record.value.name) {
1129                CycleReference::Generated { id, .. } => id.as_str().to_string(),
1130                _ => record.value.name.clone(),
1131            };
1132            for event in history.events() {
1133                if !matches!(
1134                    event.kind,
1135                    HistoryEventKind::CycleClosed | HistoryEventKind::CycleReopened
1136                ) {
1137                    continue;
1138                }
1139                let projected = PortableCapacityHistoryEvent {
1140                    cycle_id: cycle_id.clone(),
1141                    cycle_name: record.value.name.clone(),
1142                    kind: event.kind.as_str().to_string(),
1143                    timestamp: event.timestamp.clone(),
1144                    actor: event.actor.clone(),
1145                    detail: event.detail.clone(),
1146                };
1147                if filter
1148                    .as_ref()
1149                    .is_some_and(|filter| !filter.keeps_history(&projected))
1150                {
1151                    continue;
1152                }
1153                document.history.push(projected);
1154            }
1155        }
1156
1157        // The remote half: a remote tracker has no capacity field, and saying so is the
1158        // deliverable. One finding per configured target — the values stay local.
1159        document.diagnostics.extend(remote_diagnostics(
1160            config,
1161            document.scopes.len(),
1162            document.snapshots.len(),
1163        ));
1164
1165        Ok(document)
1166    }
1167
1168    /// Whether this document carries no capacity at all.
1169    #[must_use]
1170    pub fn is_empty(&self) -> bool {
1171        self.scopes.is_empty() && self.snapshots.is_empty() && self.history.is_empty()
1172    }
1173
1174    /// The document's format marker.
1175    #[must_use]
1176    pub fn format(&self) -> &str {
1177        &self.format
1178    }
1179
1180    /// The document's format version.
1181    #[must_use]
1182    pub const fn version(&self) -> u32 {
1183        self.version
1184    }
1185
1186    /// Preserve one document-level key, in the nested `extensions` object.
1187    ///
1188    /// The **only** public write path into a bag, and it cannot produce a document this
1189    /// build cannot read: the key lands in a namespace of its own, so it can never
1190    /// duplicate — or be read in place of — a document field.
1191    pub fn insert_extension(&mut self, key: impl Into<String>, value: serde_json::Value) {
1192        self.extensions.insert(key, value);
1193    }
1194
1195    /// Serialize the document as compact JSON.
1196    pub fn to_json(&self) -> Result<String> {
1197        serde_json::to_string(self).map_err(|error| {
1198            TixError::Parse(format!("capacity document cannot be serialized: {error}"))
1199        })
1200    }
1201
1202    /// Serialize the document as pretty JSON — the export surface's shape.
1203    pub fn to_json_pretty(&self) -> Result<String> {
1204        serde_json::to_string_pretty(self).map_err(|error| {
1205            TixError::Parse(format!("capacity document cannot be serialized: {error}"))
1206        })
1207    }
1208
1209    /// Read a document back.
1210    ///
1211    /// The [`Marker`] is read **first**: a document that is not a capacity document is
1212    /// refused by name (its format marker) rather than by whatever shape error its fields
1213    /// happened to raise — a `tix-health` or `tix-roadmap` payload overlaps structurally and
1214    /// used to produce a misleading field error. A document of another *version* is read,
1215    /// its own fields and preserved keys kept, and the mismatch is merged into the
1216    /// diagnostics once — the checked-and-reported third answer between refusing a newer
1217    /// writer and silently dropping its fields.
1218    pub fn from_json(raw: &str) -> Result<Self> {
1219        let marker: Marker = serde_json::from_str(raw).map_err(|error| {
1220            TixError::Parse(format!("not a portable capacity document: {error}"))
1221        })?;
1222        if marker.format != FORMAT {
1223            return Err(TixError::Parse(format!(
1224                "portable capacity document has format `{}`, but this build reads `{FORMAT}`",
1225                marker.format
1226            )));
1227        }
1228        let mut document: Self = serde_json::from_str(raw).map_err(|error| {
1229            TixError::Parse(format!(
1230                "portable capacity document is not valid JSON of this shape: {error}"
1231            ))
1232        })?;
1233        if document.version != VERSION {
1234            let diagnostic = PortableCapacityDiagnostic::version_mismatch(document.version);
1235            if !document
1236                .diagnostics
1237                .iter()
1238                .any(|existing| existing.same_as(&diagnostic))
1239            {
1240                document.diagnostics.push(diagnostic);
1241            }
1242        }
1243        Ok(document)
1244    }
1245}
1246
1247/// The remote-target findings: one per configured `[export.*]` target.
1248///
1249/// The targets are the configuration's own (`config.export.gitlab`/`.linear`), and the
1250/// finding names the target and the field it cannot carry. It is **not** a mapping: no
1251/// remote id is invented, no value is converted, and nothing is sent anywhere.
1252fn remote_diagnostics(
1253    config: &Config,
1254    scopes: usize,
1255    snapshots: usize,
1256) -> Vec<PortableCapacityDiagnostic> {
1257    let mut targets = Vec::new();
1258    if config.export.gitlab.is_some() {
1259        targets.push("gitlab");
1260    }
1261    if config.export.linear.is_some() {
1262        targets.push("linear");
1263    }
1264    if targets.is_empty() {
1265        return Vec::new();
1266    }
1267    targets
1268        .into_iter()
1269        .map(|target| PortableCapacityDiagnostic::unsupported_remote(target, scopes, snapshots))
1270        .collect()
1271}