Skip to main content

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}