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 ¬_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 ¬_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}