tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
//! The portable roadmap document: one versioned DTO carrying the roadmap
//! projection's rows — their stable ids, metadata, typed associations, normalized
//! filter, per-source contributions with provenance and policy versions, and the
//! diagnostics — so the CLI, the TUI, MCP and an export file cannot disagree about
//! the same snapshot (mission `roadmap-initiatives-01M3C8SX`, WP07 T032/T033; C-007,
//! C-009).
//!
//! # One DTO, or four opinions
//!
//! C-007 requires the CLI, the TUI, MCP and the portable surface to consume **one**
//! roadmap shape. This is it, and it is built from the one projection
//! ([`crate::roadmap::projection::project`]) every surface already reads — a surface
//! renders the document; it does not recompute the rows. The consequence that
//! matters is that `tix roadmap list`, an MCP roadmap tool and
//! `tix export --target roadmap` cannot drift, because there is no second
//! implementation of ordering, filtering or the summary join to drift from.
//!
//! # What is carried, and what is not
//!
//! This is a **surface projection**, not a byte-for-byte mirror of
//! `data-model.md` §4. What it carries is what a surface needs to be equal to
//! another: each row's stable id and metadata, its references with resolution, one
//! contribution per source entity (the `<kind>:<id>` provenance, the derived label,
//! the resolution, the shared health policy's typed risk **state** and version, and
//! the owning service's own `done`/`total`), the normalized filter, and the ordered
//! diagnostics. What it deliberately does **not** carry: `InitiativeSummary::initiative_id`
//! (it is the row `id` — the same stable id, so carrying it twice would be a second
//! spelling to keep in step), the full project/epic rollups behind a contribution
//! (only their risk state and figures reach here; the owning service remains the
//! authority for the rest), and anything derived from a source that a surface can
//! read back from the projection itself.
//!
//! # A join, not a blend (C-004)
//!
//! Every summary the document carries is the projection's **join**: one contribution
//! per referenced source entity, each with its own owning-service value
//! (`done`/`total` as that service returned it), its stable `<kind>:<id>`
//! provenance, and the shared health policy version. There is no aggregate
//! percentage, score, average or rate — not in the rows and not in the remotest
//! corner of the mapping — because inventing one would be the second metrics engine
//! the mission forbids.
//!
//! # Loss-aware, not lossless-by-accident
//!
//! The document preserves a reference that does not resolve (`resolution =
//! "missing"`), a **missing** or **ambiguous** reference spelling, an **empty**
//! summary, and every diagnostic, verbatim. It also carries the keys a *future*
//! build adds: `#[serde(flatten)] extensions` keeps unknown top-level keys, so a
//! round trip through a build that predates a field must not delete it (C-009).
//! The **values** are preserved verbatim; the map is a sorted map, so key **order**
//! is normalised rather than remembered — a round trip preserves what was there, not
//! the order it arrived in. Nothing is inferred from an owner and nothing is
//! silently dropped: the document is the portable half of C-005/C-006.
//!
//! # A closed version, refused by name
//!
//! The document carries a [`FORMAT`] marker and a [`VERSION`]. A document from a
//! version this build cannot read is **refused**, naming the version, rather than
//! deserialized into a shaped-but-wrong value — the same rule the health policy
//! applies to an unknown policy version (`HealthPolicy::for_version`). The
//! forward-compatibility budget is spent on the *preserved extension keys*, not on a
//! reader that guesses what a future version meant.
//!
//! # Round trips are idempotent
//!
//! `document → JSON → document → JSON` is byte-stable: [`PortableRoadmap::from_json`]
//! re-derives nothing (there is no second projection to re-run) and rewrites every
//! field it read, so a document stabilizes after one write instead of growing on
//! every read. A document that carried an unknown diagnostic kind keeps it as a
//! string — the reader does not need to interpret a kind to preserve it.

use serde::{Deserialize, Serialize};

use crate::config::Config;
use crate::error::{Result, TixError};
use crate::roadmap::diagnostics::RoadmapDiagnostic;
use crate::roadmap::filter::RoadmapFilter;
use crate::roadmap::projection::{
    self, InitiativeSummary, ProgressValue, RoadmapProjection, RoadmapRow, RoadmapSources,
    SummaryContribution,
};
use crate::sync::{RemoteMappingDiagnostic, Tool};

/// The document's format marker.
pub const FORMAT: &str = "tix-roadmap";

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

/// The stable kind spelling of a remote-mapping diagnostic.
///
/// The same spelling the health portable document uses for the same finding, so a
/// reader who learned one vocabulary reads the other.
pub const UNSUPPORTED_REMOTE_FIELD: &str = "unsupported_remote_field";

/// The document's own top-level keys.
///
/// A preserved extension key must not **shadow** a document field, the same rule
/// `INITIATIVE_KNOWN_FIELDS` enforces for an initiative record (`src/config.rs`).
/// A file can never deliver one — serde matches a known field first, so the
/// extension map is only ever the leftovers — but the public write path can inject
/// one through [`PortableExtensions::insert`], and serde would then see the same key
/// twice and fail to re-read the document it just wrote. It is listed once, here,
/// and checked in the one inserter.
const PORTABLE_KNOWN_FIELDS: &[&str] = &[
    "format",
    "version",
    "filters",
    "rows",
    "total",
    "diagnostics",
];

/// The forward-compatibility bag: top-level keys this build does not interpret.
///
/// A newtype over a JSON value map, rather than a bare `Map`, so the write path goes
/// through [`PortableExtensions::insert`] and a key that would collide with a
/// document field is refused by name instead of producing a document that cannot be
/// read back. A JSON map (not `toml::Table`) because TOML has no null: a newer build
/// writing `"future": null` must survive a round trip through this one, and a TOML
/// value cannot hold a null at all.
///
/// The map is sorted (the `preserve_order` feature is off), so key order is
/// normalised — the values are what the round trip preserves.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PortableExtensions(serde_json::Map<String, serde_json::Value>);

impl PortableExtensions {
    /// An empty bag.
    #[must_use]
    pub fn new() -> Self {
        Self(serde_json::Map::new())
    }

    /// Whether no unknown metadata is present.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    /// The number of preserved keys.
    #[must_use]
    pub fn len(&self) -> usize {
        self.0.len()
    }

    /// Whether a preserved key is present.
    #[must_use]
    pub fn contains_key(&self, key: &str) -> bool {
        self.0.contains_key(key)
    }

    /// Look up a preserved key.
    #[must_use]
    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
        self.0.get(key)
    }

    /// Preserve one `(key, value)` pair.
    ///
    /// Refuses a key that names a document field rather than letting it shadow the
    /// real one: this guard is what keeps "a document this build writes is one it can
    /// read back" true.
    pub fn insert(&mut self, key: impl Into<String>, value: serde_json::Value) -> Result<()> {
        let key = key.into();
        if PORTABLE_KNOWN_FIELDS.contains(&key.as_str()) {
            return Err(TixError::InvalidInput(format!(
                "`{key}` collides with a document field; rename the extension key"
            )));
        }
        self.0.insert(key, value);
        Ok(())
    }
}

/// Everything the roadmap projection knows, in one versioned document (T032).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableRoadmap {
    /// The format marker: [`FORMAT`].
    pub format: String,
    /// The format version: [`VERSION`].
    pub version: u32,
    /// The normalized filter the projection was taken under. One DTO for every
    /// surface; the default value is the documented "no filter".
    #[serde(default)]
    pub filters: RoadmapFilter,
    /// One row per filtered initiative, in projection order.
    #[serde(default)]
    pub rows: Vec<PortableInitiativeRow>,
    /// The row count after filtering.
    #[serde(default)]
    pub total: usize,
    /// Every row's findings, flattened and ordered, exactly as the projection
    /// reports them.
    #[serde(default)]
    pub diagnostics: Vec<PortableRoadmapDiagnostic>,
    /// Keys this build does not interpret, preserved verbatim (values; key order is
    /// normalised).
    #[serde(flatten)]
    pub extensions: PortableExtensions,
}

/// One displayable initiative row (data-model §6), portable.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableInitiativeRow {
    /// The stable initiative id (C-002) — the identity, never the name.
    pub id: String,
    /// The display label.
    pub name: String,
    /// The optional strategic outcome.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub outcome: Option<String>,
    /// The optional owner identity, as recorded (never inferred).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub owner: Option<String>,
    /// The optional initiative status.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
    /// The optional priority, in its own stable spelling.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub priority: Option<String>,
    /// The filterable labels.
    #[serde(default)]
    pub labels: Vec<String>,
    /// The target date; `None` is the explicit undated state (C-003).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target_date: Option<String>,
    /// The record's references, with resolution.
    #[serde(default)]
    pub references: Vec<PortableReference>,
    /// The source-attributed summary.
    pub summary: PortableSummary,
    /// The row's ordered findings.
    #[serde(default)]
    pub diagnostics: Vec<PortableRoadmapDiagnostic>,
}

/// One association as the row exposes it: identity, label and resolution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableReference {
    /// The record field the reference lives under (`projects`, `epics`,
    /// `milestones`).
    pub field: String,
    /// The recorded target.
    pub target: String,
    /// The derived display label (the target when unresolved).
    pub label: String,
    /// How the reference resolved (`resolved`, `missing`, `ambiguous`).
    pub resolution: String,
}

/// An initiative's derived, source-attributed summary (data-model §4), portable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableSummary {
    /// The explicit summary state.
    pub state: String,
    /// One contribution per referenced source entity, in reference order.
    #[serde(default)]
    pub contributions: Vec<PortableContribution>,
    /// The resolved source entities, `<kind>:<id>`, in contribution order.
    #[serde(default)]
    pub source_entities: Vec<String>,
    /// The policy versions among the contributions, ordered and de-duplicated.
    #[serde(default)]
    pub policy_versions: Vec<String>,
    /// The ordered, field-aware findings for this summary.
    #[serde(default)]
    pub diagnostics: Vec<PortableRoadmapDiagnostic>,
}

/// One source entity's contribution — the join's atom, portable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableContribution {
    /// The record field the reference lives under.
    pub field: String,
    /// The stable source-entity provenance spelling, `<kind>:<id>`.
    pub source_entity: String,
    /// The recorded target.
    pub target: String,
    /// The derived display label.
    pub label: String,
    /// How the reference resolved.
    pub resolution: String,
    /// The owning service's own value, verbatim: the counts that service returned.
    /// `None` when the source has no value (a milestone) or did not resolve. Never
    /// a cross-source aggregate.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub value: Option<PortableValue>,
    /// The shared health policy's typed risk state for the source's scope.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub risk_state: Option<String>,
    /// The policy version that produced the value/risk, when present.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub policy_version: Option<String>,
}

/// One owning service's returned figures, verbatim — a single service's counts,
/// never a blend across services (C-004).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableValue {
    /// The service's own completed count.
    pub done: usize,
    /// The service's own population.
    pub total: usize,
}

/// One diagnostic, in the wire vocabulary this document carries.
///
/// The shape mirrors the projection's [`RoadmapDiagnostic`] and adds the `target`
/// the remote-mapping diagnostics name, so one round trip carries both a
/// missing-reference finding and an unsupported-remote-field finding without a
/// second list.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableRoadmapDiagnostic {
    /// The stable kind spelling.
    pub kind: String,
    /// The initiative (stable id) this finding belongs to, when it is row-local.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub initiative: Option<String>,
    /// The record (or source) field the problem sits at.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub field: Option<String>,
    /// The reference that could not be resolved, when the problem is a reference.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reference: Option<String>,
    /// The source entity that reported the finding, when it is forwarded.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,
    /// The remote target, when the finding is about a mapping.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target: Option<String>,
    /// What is wrong.
    pub message: String,
    /// What to do about it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub remedy: Option<String>,
}

impl PortableRoadmapDiagnostic {
    /// Project a roadmap finding into the wire vocabulary.
    #[must_use]
    pub fn from_roadmap(diagnostic: &RoadmapDiagnostic) -> Self {
        Self {
            kind: diagnostic.kind.as_str().to_string(),
            initiative: diagnostic.initiative.clone(),
            field: diagnostic.field.clone(),
            reference: diagnostic.reference.clone(),
            source: diagnostic.source.clone(),
            target: None,
            message: diagnostic.message.clone(),
            remedy: diagnostic.remedy.clone(),
        }
    }

    /// Carry a remote-mapping diagnostic, naming the **field** and the **target**.
    ///
    /// The mapping produced a [`RemoteMappingDiagnostic`] (the shared type, not a
    /// new one); this is that value in the document's vocabulary, `unsupported_remote_field`
    /// being the stable kind the health document uses for the same finding.
    #[must_use]
    pub fn from_remote(initiative: &str, diagnostic: &RemoteMappingDiagnostic) -> Self {
        Self {
            kind: UNSUPPORTED_REMOTE_FIELD.to_string(),
            initiative: Some(initiative.to_string()),
            field: Some(diagnostic.field.clone()),
            reference: Some(diagnostic.relation_id.clone()),
            source: None,
            target: Some(diagnostic.tool.as_str().to_string()),
            message: diagnostic.message.clone(),
            remedy: Some(
                "keep the local record; the remote cannot hold this field, and a remote that \
                 cannot hold it never clears it"
                    .to_string(),
            ),
        }
    }
    /// Render the finding for a human reading stderr.
    ///
    /// The same shape the health document's diagnostics render in, so a reader who
    /// learned one reads the other: the message, the kind and the initiative, then
    /// the field, the unresolved reference and the remote target when there is one.
    #[must_use]
    pub fn render(&self) -> String {
        let mut diagnostic =
            crate::diagnostics::Diagnostic::warning("W-tix-roadmap", self.message.clone())
                .with_note(format!(
                    "{}: {}",
                    self.kind,
                    self.initiative.as_deref().unwrap_or("-")
                ));
        if let Some(field) = &self.field {
            diagnostic = diagnostic.with_note(format!("field: {field}"));
        }
        if let Some(reference) = &self.reference {
            diagnostic = diagnostic.with_note(format!("reference: {reference}"));
        }
        if let Some(target) = &self.target {
            diagnostic = diagnostic.with_note(format!("target: {target}"));
        }
        if let Some(remedy) = &self.remedy {
            diagnostic = diagnostic.with_help(remedy.clone());
        }
        diagnostic.render()
    }

    /// Whether two diagnostics describe the same problem.
    #[must_use]
    pub fn same_as(&self, other: &Self) -> bool {
        self == other
    }
}

impl PortableRoadmap {
    /// Build the document from one projection (T032).
    ///
    /// The projection is the one **all** surfaces read
    /// ([`crate::roadmap::projection::project`]), so the document is what the CLI,
    /// the TUI and MCP see for the same snapshot by construction — not a second
    /// projection that could disagree (C-007).
    pub fn build(
        config: &Config,
        sources: &RoadmapSources<'_>,
        filter: &RoadmapFilter,
    ) -> Result<Self> {
        let projection = projection::project(config, sources, filter)?;
        Ok(Self::from_projection(&projection))
    }

    /// Read a projection into the portable form, without re-deriving anything.
    #[must_use]
    pub fn from_projection(projection: &RoadmapProjection) -> Self {
        let rows: Vec<PortableInitiativeRow> = projection
            .initiatives
            .iter()
            .map(PortableInitiativeRow::from_row)
            .collect();
        Self {
            format: FORMAT.to_string(),
            version: VERSION,
            filters: projection.filters.clone(),
            total: rows.len(),
            rows,
            diagnostics: projection
                .diagnostics
                .iter()
                .map(PortableRoadmapDiagnostic::from_roadmap)
                .collect(),
            extensions: PortableExtensions::new(),
        }
    }

    /// Append one diagnostic per configured remote target, per initiative (T035).
    ///
    /// The mapping is the shared seam ([`crate::sync::map_initiative_to_remote`]) and
    /// the target is a configured `[export.gitlab]`/`[export.linear]` section, exactly
    /// as the health document's remote half is composed. The finding names the remote
    /// **field** and the **target** and is purely additive: the local record is
    /// untouched, so a reader sees *why* an initiative stays local rather than being
    /// asked to trust that it did.
    #[must_use]
    pub fn with_remote_mappings(mut self, config: &Config) -> Self {
        let targets: Vec<Tool> = [
            (config.export.gitlab.is_some(), Tool::GitLab),
            (config.export.linear.is_some(), Tool::Linear),
        ]
        .into_iter()
        .filter_map(|(configured, tool)| configured.then_some(tool))
        .collect();
        if targets.is_empty() {
            return self;
        }
        for id in config.initiatives.keys() {
            for tool in &targets {
                let crate::sync::MappedInitiative::Unsupported(diagnostic) =
                    crate::sync::map_initiative_to_remote(*tool, id)
                else {
                    continue;
                };
                self.diagnostics
                    .push(PortableRoadmapDiagnostic::from_remote(id, &diagnostic));
            }
        }
        self
    }

    /// Serialize as compact, byte-stable JSON.
    pub fn to_json(&self) -> Result<String> {
        serde_json::to_string(self)
            .map_err(|error| TixError::Parse(format!("portable roadmap document: {error}")))
    }

    /// Serialize as indented JSON — what a human reads.
    pub fn to_json_pretty(&self) -> Result<String> {
        serde_json::to_string_pretty(self)
            .map_err(|error| TixError::Parse(format!("portable roadmap document: {error}")))
    }

    /// Read a portable document, refusing one this build cannot read (T033).
    ///
    /// The marker and version are read **first** and checked before the rest of the
    /// shape is deserialized: a document from a version this build does not implement
    /// is refused by name, rather than read into a value that looks right and means
    /// something else. A document of *this* version is read verbatim — nothing is
    /// re-derived, so the round trip is a fixed point.
    pub fn from_json(text: &str) -> Result<Self> {
        #[derive(Deserialize)]
        struct Marker {
            #[serde(default)]
            format: String,
            #[serde(default)]
            version: u32,
        }
        let marker: Marker = serde_json::from_str(text)
            .map_err(|error| TixError::Parse(format!("portable roadmap document: {error}")))?;
        if marker.format != FORMAT {
            return Err(TixError::InvalidInput(format!(
                "portable roadmap document has format `{}`, but this build reads `{FORMAT}`",
                marker.format
            )));
        }
        if marker.version != VERSION {
            return Err(TixError::InvalidInput(format!(
                "portable roadmap document declares version {}, but this build reads version \
                 {VERSION}",
                marker.version
            )));
        }
        serde_json::from_str(text)
            .map_err(|error| TixError::Parse(format!("portable roadmap document: {error}")))
    }

    /// The diagnostics about one initiative.
    #[must_use]
    pub fn diagnostics_for(&self, initiative: &str) -> Vec<&PortableRoadmapDiagnostic> {
        self.diagnostics
            .iter()
            .filter(|diagnostic| diagnostic.initiative.as_deref() == Some(initiative))
            .collect()
    }
}

impl PortableInitiativeRow {
    /// Read one projection row, field for field.
    fn from_row(row: &RoadmapRow) -> Self {
        Self {
            id: row.id.clone(),
            name: row.name.clone(),
            outcome: row.outcome.clone(),
            owner: row.owner.clone(),
            status: row.status.clone(),
            priority: row.priority.as_ref().map(ToString::to_string),
            labels: row.labels.clone(),
            target_date: row.target_date.clone(),
            references: row.references.iter().map(reference).collect(),
            summary: PortableSummary::from_summary(&row.summary),
            diagnostics: row
                .diagnostics
                .iter()
                .map(PortableRoadmapDiagnostic::from_roadmap)
                .collect(),
        }
    }
}

impl PortableSummary {
    /// Read one summary, keeping the contributions and their provenance.
    fn from_summary(summary: &InitiativeSummary) -> Self {
        Self {
            state: summary.state.as_str().to_string(),
            contributions: summary.contributions.iter().map(contribution).collect(),
            source_entities: summary.source_entities.clone(),
            policy_versions: summary.policy_versions.clone(),
            diagnostics: summary
                .diagnostics
                .iter()
                .map(PortableRoadmapDiagnostic::from_roadmap)
                .collect(),
        }
    }
}

/// One association as the row exposes it.
fn reference(value: &crate::roadmap::projection::RowReference) -> PortableReference {
    PortableReference {
        field: value.kind.field().to_string(),
        target: value.target.clone(),
        label: value.label.clone(),
        resolution: value.resolution.as_str().to_string(),
    }
}

/// One contribution, with its stable provenance and its owning service's value.
fn contribution(value: &SummaryContribution) -> PortableContribution {
    PortableContribution {
        field: value.kind.field().to_string(),
        source_entity: value.source_entity(),
        target: value.target.clone(),
        label: value.label.clone(),
        resolution: value.resolution.as_str().to_string(),
        value: match &value.progress {
            Some(ProgressValue::Project(report)) => Some(PortableValue {
                done: report.progress.done,
                total: report.progress.total,
            }),
            Some(ProgressValue::Epic(rollup)) => Some(PortableValue {
                done: rollup.progress.done,
                total: rollup.progress.total,
            }),
            None => None,
        },
        risk_state: value
            .risk
            .as_ref()
            .map(|risk| risk.state.as_str().to_string()),
        policy_version: value.policy_version.clone(),
    }
}