tix/roadmap/portable.rs
1//! The portable roadmap document: one versioned DTO carrying the roadmap
2//! projection's rows — their stable ids, metadata, typed associations, normalized
3//! filter, per-source contributions with provenance and policy versions, and the
4//! diagnostics — so the CLI, the TUI, MCP and an export file cannot disagree about
5//! the same snapshot (mission `roadmap-initiatives-01M3C8SX`, WP07 T032/T033; C-007,
6//! C-009).
7//!
8//! # One DTO, or four opinions
9//!
10//! C-007 requires the CLI, the TUI, MCP and the portable surface to consume **one**
11//! roadmap shape. This is it, and it is built from the one projection
12//! ([`crate::roadmap::projection::project`]) every surface already reads — a surface
13//! renders the document; it does not recompute the rows. The consequence that
14//! matters is that `tix roadmap list`, an MCP roadmap tool and
15//! `tix export --target roadmap` cannot drift, because there is no second
16//! implementation of ordering, filtering or the summary join to drift from.
17//!
18//! # What is carried, and what is not
19//!
20//! This is a **surface projection**, not a byte-for-byte mirror of
21//! `data-model.md` §4. What it carries is what a surface needs to be equal to
22//! another: each row's stable id and metadata, its references with resolution, one
23//! contribution per source entity (the `<kind>:<id>` provenance, the derived label,
24//! the resolution, the shared health policy's typed risk **state** and version, and
25//! the owning service's own `done`/`total`), the normalized filter, and the ordered
26//! diagnostics. What it deliberately does **not** carry: `InitiativeSummary::initiative_id`
27//! (it is the row `id` — the same stable id, so carrying it twice would be a second
28//! spelling to keep in step), the full project/epic rollups behind a contribution
29//! (only their risk state and figures reach here; the owning service remains the
30//! authority for the rest), and anything derived from a source that a surface can
31//! read back from the projection itself.
32//!
33//! # A join, not a blend (C-004)
34//!
35//! Every summary the document carries is the projection's **join**: one contribution
36//! per referenced source entity, each with its own owning-service value
37//! (`done`/`total` as that service returned it), its stable `<kind>:<id>`
38//! provenance, and the shared health policy version. There is no aggregate
39//! percentage, score, average or rate — not in the rows and not in the remotest
40//! corner of the mapping — because inventing one would be the second metrics engine
41//! the mission forbids.
42//!
43//! # Loss-aware, not lossless-by-accident
44//!
45//! The document preserves a reference that does not resolve (`resolution =
46//! "missing"`), a **missing** or **ambiguous** reference spelling, an **empty**
47//! summary, and every diagnostic, verbatim. It also carries the keys a *future*
48//! build adds: `#[serde(flatten)] extensions` keeps unknown top-level keys, so a
49//! round trip through a build that predates a field must not delete it (C-009).
50//! The **values** are preserved verbatim; the map is a sorted map, so key **order**
51//! is normalised rather than remembered — a round trip preserves what was there, not
52//! the order it arrived in. Nothing is inferred from an owner and nothing is
53//! silently dropped: the document is the portable half of C-005/C-006.
54//!
55//! # A closed version, refused by name
56//!
57//! The document carries a [`FORMAT`] marker and a [`VERSION`]. A document from a
58//! version this build cannot read is **refused**, naming the version, rather than
59//! deserialized into a shaped-but-wrong value — the same rule the health policy
60//! applies to an unknown policy version (`HealthPolicy::for_version`). The
61//! forward-compatibility budget is spent on the *preserved extension keys*, not on a
62//! reader that guesses what a future version meant.
63//!
64//! # Round trips are idempotent
65//!
66//! `document → JSON → document → JSON` is byte-stable: [`PortableRoadmap::from_json`]
67//! re-derives nothing (there is no second projection to re-run) and rewrites every
68//! field it read, so a document stabilizes after one write instead of growing on
69//! every read. A document that carried an unknown diagnostic kind keeps it as a
70//! string — the reader does not need to interpret a kind to preserve it.
71
72use serde::{Deserialize, Serialize};
73
74use crate::config::Config;
75use crate::error::{Result, TixError};
76use crate::roadmap::diagnostics::RoadmapDiagnostic;
77use crate::roadmap::filter::RoadmapFilter;
78use crate::roadmap::projection::{
79 self, InitiativeSummary, ProgressValue, RoadmapProjection, RoadmapRow, RoadmapSources,
80 SummaryContribution,
81};
82use crate::sync::{RemoteMappingDiagnostic, Tool};
83
84/// The document's format marker.
85pub const FORMAT: &str = "tix-roadmap";
86
87/// The document's format version. Bumped when the shape changes incompatibly.
88pub const VERSION: u32 = 1;
89
90/// The stable kind spelling of a remote-mapping diagnostic.
91///
92/// The same spelling the health portable document uses for the same finding, so a
93/// reader who learned one vocabulary reads the other.
94pub const UNSUPPORTED_REMOTE_FIELD: &str = "unsupported_remote_field";
95
96/// The document's own top-level keys.
97///
98/// A preserved extension key must not **shadow** a document field, the same rule
99/// `INITIATIVE_KNOWN_FIELDS` enforces for an initiative record (`src/config.rs`).
100/// A file can never deliver one — serde matches a known field first, so the
101/// extension map is only ever the leftovers — but the public write path can inject
102/// one through [`PortableExtensions::insert`], and serde would then see the same key
103/// twice and fail to re-read the document it just wrote. It is listed once, here,
104/// and checked in the one inserter.
105const PORTABLE_KNOWN_FIELDS: &[&str] = &[
106 "format",
107 "version",
108 "filters",
109 "rows",
110 "total",
111 "diagnostics",
112];
113
114/// The forward-compatibility bag: top-level keys this build does not interpret.
115///
116/// A newtype over a JSON value map, rather than a bare `Map`, so the write path goes
117/// through [`PortableExtensions::insert`] and a key that would collide with a
118/// document field is refused by name instead of producing a document that cannot be
119/// read back. A JSON map (not `toml::Table`) because TOML has no null: a newer build
120/// writing `"future": null` must survive a round trip through this one, and a TOML
121/// value cannot hold a null at all.
122///
123/// The map is sorted (the `preserve_order` feature is off), so key order is
124/// normalised — the values are what the round trip preserves.
125#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
126#[serde(transparent)]
127pub struct PortableExtensions(serde_json::Map<String, serde_json::Value>);
128
129impl PortableExtensions {
130 /// An empty bag.
131 #[must_use]
132 pub fn new() -> Self {
133 Self(serde_json::Map::new())
134 }
135
136 /// Whether no unknown metadata is present.
137 #[must_use]
138 pub fn is_empty(&self) -> bool {
139 self.0.is_empty()
140 }
141
142 /// The number of preserved keys.
143 #[must_use]
144 pub fn len(&self) -> usize {
145 self.0.len()
146 }
147
148 /// Whether a preserved key is present.
149 #[must_use]
150 pub fn contains_key(&self, key: &str) -> bool {
151 self.0.contains_key(key)
152 }
153
154 /// Look up a preserved key.
155 #[must_use]
156 pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
157 self.0.get(key)
158 }
159
160 /// Preserve one `(key, value)` pair.
161 ///
162 /// Refuses a key that names a document field rather than letting it shadow the
163 /// real one: this guard is what keeps "a document this build writes is one it can
164 /// read back" true.
165 pub fn insert(&mut self, key: impl Into<String>, value: serde_json::Value) -> Result<()> {
166 let key = key.into();
167 if PORTABLE_KNOWN_FIELDS.contains(&key.as_str()) {
168 return Err(TixError::InvalidInput(format!(
169 "`{key}` collides with a document field; rename the extension key"
170 )));
171 }
172 self.0.insert(key, value);
173 Ok(())
174 }
175}
176
177/// Everything the roadmap projection knows, in one versioned document (T032).
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179pub struct PortableRoadmap {
180 /// The format marker: [`FORMAT`].
181 pub format: String,
182 /// The format version: [`VERSION`].
183 pub version: u32,
184 /// The normalized filter the projection was taken under. One DTO for every
185 /// surface; the default value is the documented "no filter".
186 #[serde(default)]
187 pub filters: RoadmapFilter,
188 /// One row per filtered initiative, in projection order.
189 #[serde(default)]
190 pub rows: Vec<PortableInitiativeRow>,
191 /// The row count after filtering.
192 #[serde(default)]
193 pub total: usize,
194 /// Every row's findings, flattened and ordered, exactly as the projection
195 /// reports them.
196 #[serde(default)]
197 pub diagnostics: Vec<PortableRoadmapDiagnostic>,
198 /// Keys this build does not interpret, preserved verbatim (values; key order is
199 /// normalised).
200 #[serde(flatten)]
201 pub extensions: PortableExtensions,
202}
203
204/// One displayable initiative row (data-model §6), portable.
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct PortableInitiativeRow {
207 /// The stable initiative id (C-002) — the identity, never the name.
208 pub id: String,
209 /// The display label.
210 pub name: String,
211 /// The optional strategic outcome.
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub outcome: Option<String>,
214 /// The optional owner identity, as recorded (never inferred).
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub owner: Option<String>,
217 /// The optional initiative status.
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub status: Option<String>,
220 /// The optional priority, in its own stable spelling.
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub priority: Option<String>,
223 /// The filterable labels.
224 #[serde(default)]
225 pub labels: Vec<String>,
226 /// The target date; `None` is the explicit undated state (C-003).
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub target_date: Option<String>,
229 /// The record's references, with resolution.
230 #[serde(default)]
231 pub references: Vec<PortableReference>,
232 /// The source-attributed summary.
233 pub summary: PortableSummary,
234 /// The row's ordered findings.
235 #[serde(default)]
236 pub diagnostics: Vec<PortableRoadmapDiagnostic>,
237}
238
239/// One association as the row exposes it: identity, label and resolution.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct PortableReference {
242 /// The record field the reference lives under (`projects`, `epics`,
243 /// `milestones`).
244 pub field: String,
245 /// The recorded target.
246 pub target: String,
247 /// The derived display label (the target when unresolved).
248 pub label: String,
249 /// How the reference resolved (`resolved`, `missing`, `ambiguous`).
250 pub resolution: String,
251}
252
253/// An initiative's derived, source-attributed summary (data-model §4), portable.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct PortableSummary {
256 /// The explicit summary state.
257 pub state: String,
258 /// One contribution per referenced source entity, in reference order.
259 #[serde(default)]
260 pub contributions: Vec<PortableContribution>,
261 /// The resolved source entities, `<kind>:<id>`, in contribution order.
262 #[serde(default)]
263 pub source_entities: Vec<String>,
264 /// The policy versions among the contributions, ordered and de-duplicated.
265 #[serde(default)]
266 pub policy_versions: Vec<String>,
267 /// The ordered, field-aware findings for this summary.
268 #[serde(default)]
269 pub diagnostics: Vec<PortableRoadmapDiagnostic>,
270}
271
272/// One source entity's contribution — the join's atom, portable.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274pub struct PortableContribution {
275 /// The record field the reference lives under.
276 pub field: String,
277 /// The stable source-entity provenance spelling, `<kind>:<id>`.
278 pub source_entity: String,
279 /// The recorded target.
280 pub target: String,
281 /// The derived display label.
282 pub label: String,
283 /// How the reference resolved.
284 pub resolution: String,
285 /// The owning service's own value, verbatim: the counts that service returned.
286 /// `None` when the source has no value (a milestone) or did not resolve. Never
287 /// a cross-source aggregate.
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub value: Option<PortableValue>,
290 /// The shared health policy's typed risk state for the source's scope.
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub risk_state: Option<String>,
293 /// The policy version that produced the value/risk, when present.
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub policy_version: Option<String>,
296}
297
298/// One owning service's returned figures, verbatim — a single service's counts,
299/// never a blend across services (C-004).
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
301pub struct PortableValue {
302 /// The service's own completed count.
303 pub done: usize,
304 /// The service's own population.
305 pub total: usize,
306}
307
308/// One diagnostic, in the wire vocabulary this document carries.
309///
310/// The shape mirrors the projection's [`RoadmapDiagnostic`] and adds the `target`
311/// the remote-mapping diagnostics name, so one round trip carries both a
312/// missing-reference finding and an unsupported-remote-field finding without a
313/// second list.
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315pub struct PortableRoadmapDiagnostic {
316 /// The stable kind spelling.
317 pub kind: String,
318 /// The initiative (stable id) this finding belongs to, when it is row-local.
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub initiative: Option<String>,
321 /// The record (or source) field the problem sits at.
322 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub field: Option<String>,
324 /// The reference that could not be resolved, when the problem is a reference.
325 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub reference: Option<String>,
327 /// The source entity that reported the finding, when it is forwarded.
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub source: Option<String>,
330 /// The remote target, when the finding is about a mapping.
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub target: Option<String>,
333 /// What is wrong.
334 pub message: String,
335 /// What to do about it.
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub remedy: Option<String>,
338}
339
340impl PortableRoadmapDiagnostic {
341 /// Project a roadmap finding into the wire vocabulary.
342 #[must_use]
343 pub fn from_roadmap(diagnostic: &RoadmapDiagnostic) -> Self {
344 Self {
345 kind: diagnostic.kind.as_str().to_string(),
346 initiative: diagnostic.initiative.clone(),
347 field: diagnostic.field.clone(),
348 reference: diagnostic.reference.clone(),
349 source: diagnostic.source.clone(),
350 target: None,
351 message: diagnostic.message.clone(),
352 remedy: diagnostic.remedy.clone(),
353 }
354 }
355
356 /// Carry a remote-mapping diagnostic, naming the **field** and the **target**.
357 ///
358 /// The mapping produced a [`RemoteMappingDiagnostic`] (the shared type, not a
359 /// new one); this is that value in the document's vocabulary, `unsupported_remote_field`
360 /// being the stable kind the health document uses for the same finding.
361 #[must_use]
362 pub fn from_remote(initiative: &str, diagnostic: &RemoteMappingDiagnostic) -> Self {
363 Self {
364 kind: UNSUPPORTED_REMOTE_FIELD.to_string(),
365 initiative: Some(initiative.to_string()),
366 field: Some(diagnostic.field.clone()),
367 reference: Some(diagnostic.relation_id.clone()),
368 source: None,
369 target: Some(diagnostic.tool.as_str().to_string()),
370 message: diagnostic.message.clone(),
371 remedy: Some(
372 "keep the local record; the remote cannot hold this field, and a remote that \
373 cannot hold it never clears it"
374 .to_string(),
375 ),
376 }
377 }
378 /// Render the finding for a human reading stderr.
379 ///
380 /// The same shape the health document's diagnostics render in, so a reader who
381 /// learned one reads the other: the message, the kind and the initiative, then
382 /// the field, the unresolved reference and the remote target when there is one.
383 #[must_use]
384 pub fn render(&self) -> String {
385 let mut diagnostic =
386 crate::diagnostics::Diagnostic::warning("W-tix-roadmap", self.message.clone())
387 .with_note(format!(
388 "{}: {}",
389 self.kind,
390 self.initiative.as_deref().unwrap_or("-")
391 ));
392 if let Some(field) = &self.field {
393 diagnostic = diagnostic.with_note(format!("field: {field}"));
394 }
395 if let Some(reference) = &self.reference {
396 diagnostic = diagnostic.with_note(format!("reference: {reference}"));
397 }
398 if let Some(target) = &self.target {
399 diagnostic = diagnostic.with_note(format!("target: {target}"));
400 }
401 if let Some(remedy) = &self.remedy {
402 diagnostic = diagnostic.with_help(remedy.clone());
403 }
404 diagnostic.render()
405 }
406
407 /// Whether two diagnostics describe the same problem.
408 #[must_use]
409 pub fn same_as(&self, other: &Self) -> bool {
410 self == other
411 }
412}
413
414impl PortableRoadmap {
415 /// Build the document from one projection (T032).
416 ///
417 /// The projection is the one **all** surfaces read
418 /// ([`crate::roadmap::projection::project`]), so the document is what the CLI,
419 /// the TUI and MCP see for the same snapshot by construction — not a second
420 /// projection that could disagree (C-007).
421 pub fn build(
422 config: &Config,
423 sources: &RoadmapSources<'_>,
424 filter: &RoadmapFilter,
425 ) -> Result<Self> {
426 let projection = projection::project(config, sources, filter)?;
427 Ok(Self::from_projection(&projection))
428 }
429
430 /// Read a projection into the portable form, without re-deriving anything.
431 #[must_use]
432 pub fn from_projection(projection: &RoadmapProjection) -> Self {
433 let rows: Vec<PortableInitiativeRow> = projection
434 .initiatives
435 .iter()
436 .map(PortableInitiativeRow::from_row)
437 .collect();
438 Self {
439 format: FORMAT.to_string(),
440 version: VERSION,
441 filters: projection.filters.clone(),
442 total: rows.len(),
443 rows,
444 diagnostics: projection
445 .diagnostics
446 .iter()
447 .map(PortableRoadmapDiagnostic::from_roadmap)
448 .collect(),
449 extensions: PortableExtensions::new(),
450 }
451 }
452
453 /// Append one diagnostic per configured remote target, per initiative (T035).
454 ///
455 /// The mapping is the shared seam ([`crate::sync::map_initiative_to_remote`]) and
456 /// the target is a configured `[export.gitlab]`/`[export.linear]` section, exactly
457 /// as the health document's remote half is composed. The finding names the remote
458 /// **field** and the **target** and is purely additive: the local record is
459 /// untouched, so a reader sees *why* an initiative stays local rather than being
460 /// asked to trust that it did.
461 #[must_use]
462 pub fn with_remote_mappings(mut self, config: &Config) -> Self {
463 let targets: Vec<Tool> = [
464 (config.export.gitlab.is_some(), Tool::GitLab),
465 (config.export.linear.is_some(), Tool::Linear),
466 ]
467 .into_iter()
468 .filter_map(|(configured, tool)| configured.then_some(tool))
469 .collect();
470 if targets.is_empty() {
471 return self;
472 }
473 for id in config.initiatives.keys() {
474 for tool in &targets {
475 let crate::sync::MappedInitiative::Unsupported(diagnostic) =
476 crate::sync::map_initiative_to_remote(*tool, id)
477 else {
478 continue;
479 };
480 self.diagnostics
481 .push(PortableRoadmapDiagnostic::from_remote(id, &diagnostic));
482 }
483 }
484 self
485 }
486
487 /// Serialize as compact, byte-stable JSON.
488 pub fn to_json(&self) -> Result<String> {
489 serde_json::to_string(self)
490 .map_err(|error| TixError::Parse(format!("portable roadmap document: {error}")))
491 }
492
493 /// Serialize as indented JSON — what a human reads.
494 pub fn to_json_pretty(&self) -> Result<String> {
495 serde_json::to_string_pretty(self)
496 .map_err(|error| TixError::Parse(format!("portable roadmap document: {error}")))
497 }
498
499 /// Read a portable document, refusing one this build cannot read (T033).
500 ///
501 /// The marker and version are read **first** and checked before the rest of the
502 /// shape is deserialized: a document from a version this build does not implement
503 /// is refused by name, rather than read into a value that looks right and means
504 /// something else. A document of *this* version is read verbatim — nothing is
505 /// re-derived, so the round trip is a fixed point.
506 pub fn from_json(text: &str) -> Result<Self> {
507 #[derive(Deserialize)]
508 struct Marker {
509 #[serde(default)]
510 format: String,
511 #[serde(default)]
512 version: u32,
513 }
514 let marker: Marker = serde_json::from_str(text)
515 .map_err(|error| TixError::Parse(format!("portable roadmap document: {error}")))?;
516 if marker.format != FORMAT {
517 return Err(TixError::InvalidInput(format!(
518 "portable roadmap document has format `{}`, but this build reads `{FORMAT}`",
519 marker.format
520 )));
521 }
522 if marker.version != VERSION {
523 return Err(TixError::InvalidInput(format!(
524 "portable roadmap document declares version {}, but this build reads version \
525 {VERSION}",
526 marker.version
527 )));
528 }
529 serde_json::from_str(text)
530 .map_err(|error| TixError::Parse(format!("portable roadmap document: {error}")))
531 }
532
533 /// The diagnostics about one initiative.
534 #[must_use]
535 pub fn diagnostics_for(&self, initiative: &str) -> Vec<&PortableRoadmapDiagnostic> {
536 self.diagnostics
537 .iter()
538 .filter(|diagnostic| diagnostic.initiative.as_deref() == Some(initiative))
539 .collect()
540 }
541}
542
543impl PortableInitiativeRow {
544 /// Read one projection row, field for field.
545 fn from_row(row: &RoadmapRow) -> Self {
546 Self {
547 id: row.id.clone(),
548 name: row.name.clone(),
549 outcome: row.outcome.clone(),
550 owner: row.owner.clone(),
551 status: row.status.clone(),
552 priority: row.priority.as_ref().map(ToString::to_string),
553 labels: row.labels.clone(),
554 target_date: row.target_date.clone(),
555 references: row.references.iter().map(reference).collect(),
556 summary: PortableSummary::from_summary(&row.summary),
557 diagnostics: row
558 .diagnostics
559 .iter()
560 .map(PortableRoadmapDiagnostic::from_roadmap)
561 .collect(),
562 }
563 }
564}
565
566impl PortableSummary {
567 /// Read one summary, keeping the contributions and their provenance.
568 fn from_summary(summary: &InitiativeSummary) -> Self {
569 Self {
570 state: summary.state.as_str().to_string(),
571 contributions: summary.contributions.iter().map(contribution).collect(),
572 source_entities: summary.source_entities.clone(),
573 policy_versions: summary.policy_versions.clone(),
574 diagnostics: summary
575 .diagnostics
576 .iter()
577 .map(PortableRoadmapDiagnostic::from_roadmap)
578 .collect(),
579 }
580 }
581}
582
583/// One association as the row exposes it.
584fn reference(value: &crate::roadmap::projection::RowReference) -> PortableReference {
585 PortableReference {
586 field: value.kind.field().to_string(),
587 target: value.target.clone(),
588 label: value.label.clone(),
589 resolution: value.resolution.as_str().to_string(),
590 }
591}
592
593/// One contribution, with its stable provenance and its owning service's value.
594fn contribution(value: &SummaryContribution) -> PortableContribution {
595 PortableContribution {
596 field: value.kind.field().to_string(),
597 source_entity: value.source_entity(),
598 target: value.target.clone(),
599 label: value.label.clone(),
600 resolution: value.resolution.as_str().to_string(),
601 value: match &value.progress {
602 Some(ProgressValue::Project(report)) => Some(PortableValue {
603 done: report.progress.done,
604 total: report.progress.total,
605 }),
606 Some(ProgressValue::Epic(rollup)) => Some(PortableValue {
607 done: rollup.progress.done,
608 total: rollup.progress.total,
609 }),
610 None => None,
611 },
612 risk_state: value
613 .risk
614 .as_ref()
615 .map(|risk| risk.state.as_str().to_string()),
616 policy_version: value.policy_version.clone(),
617 }
618}