Skip to main content

tix/views/
portable.rs

1//! The local portable saved-view document (WP07; mission
2//! `saved-issue-views-01M3C926`; T034–T038, C-001, C-002, C-003, C-005, C-007,
3//! C-009, FR-001–FR-005, NFR-002, NFR-003).
4//!
5//! [`PortableDocument`] is the **local, loss-aware interchange form** of the
6//! saved-view registry: a versioned JSON document carrying stable ids, display
7//! names, scope metadata, normalized plans, board visibility, the active marker,
8//! divergence, preserved extensions, and diagnostics. It is a *projection* of the
9//! shared types — [`SavedView`](crate::views::registry::SavedView),
10//! [`ViewScope`], [`ViewPlan`], [`ActiveViewMarker`], [`ViewDivergence`] — never a
11//! second model: every field is read through those type's own accessors, so a
12//! rename, a new optional criterion, or a display change cannot make the portable
13//! form mean something the registry does not.
14//!
15//! # Loss-aware, not lossy (NFR-002/C-009)
16//!
17//! Three bags of unknown data survive a round trip verbatim:
18//!
19//! - the record-level and scope-level `extensions` of every view, exactly as
20//!   `tix.toml` stores them;
21//! - every unknown key inside `[plan]`, `[plan.filter]`, and `[plan.visibility]`,
22//!   both in `plan` (the table as written) and in `normalized_plan` (the same
23//!   table after [`ViewPlan::to_toml`], which also re-emits its preserved keys);
24//! - document-level keys a future build may add, captured by
25//!   [`PortableDocument::extensions`].
26//!
27//! Unknown metadata is *diagnosed as well as kept*: [`PortableDocument::diagnostics`]
28//! reports it with the same [`ViewDiagnosticKind::UnknownMetadata`] vocabulary the
29//! registry and the plan already use, so no surface has to learn a second one. Two
30//! key shapes get a specific report rather than the generic "preserved" one:
31//!
32//! - a key that **names a remote export target** (`gitlab`, `linear`) — remote
33//!   namespaces are kept separate from local view data and are never read from a
34//!   view (T037);
35//! - a key that looks like **executable or query-payload content** (`query`,
36//!   `script`, `command`, …) — such a value is never interpreted, because a view
37//!   is data, not code (C-004/C-009).
38//!
39//! Either way the value is retained, never dropped: dropping it would be the
40//! silent metadata loss the mission forbids, and refusing the whole document over
41//! one unknown key would make an older `tix` unable to read a newer registry.
42//!
43//! # Remote namespaces are separate (T037)
44//!
45//! Remote export (`tix export --target gitlab|linear`, `[export.*]`,
46//! `src/sync/`) is a different concern with different storage: it writes remote
47//! ids back into issue files and shells out to `glab`/`linear-cli`. A portable
48//! view document describes *views*. The only place the two meet is
49//! [`PortableRemoteTargets`]: a read-only echo of the configured remote targets,
50//! present so a consumer can see which namespaces are remote without confusing
51//! them for view data. It is populated from
52//! [`Config::export`](crate::config::Config) by the export surface, it is never
53//! derived from a view, and the remote **token is deliberately never projected**.
54//!
55//! # Determinism
56//!
57//! `toml::Table`, `serde_json::Map`, and the `Vec`s here are all ordered
58//! (declaration order for views, sorted keys for tables), and no field is skipped
59//! when serializing, so `document → JSON → document → JSON` is **byte-identical**
60//! after the first pass. Repeated round trips are idempotent, including the
61//! diagnostics: derived document diagnostics are merged without duplication
62//! ([`PortableDocument::refresh_diagnostics`]).
63#![allow(clippy::module_name_repetitions)]
64
65use serde::{Deserialize, Serialize};
66use toml::{Table, Value};
67
68use crate::config::{Config, RemoteConfig};
69use crate::error::{Result, TixError};
70use crate::views::divergence::{ActiveViewMarker, ViewDivergence};
71use crate::views::plan::ViewPlan;
72use crate::views::registry::{
73    ProjectDirectory, SavedView, ViewDiagnostic, ViewDiagnosticKind, ViewId, ViewRegistry,
74    ViewScope,
75};
76
77/// The portable document's format marker. A consumer reads this before treating
78/// the payload as a saved-view document.
79pub const FORMAT: &str = "tix.saved-views";
80
81/// The portable document version this build writes and reads.
82///
83/// A document with a different version is still read: its own fields are
84/// preserved and the mismatch is reported as a diagnostic rather than a refusal,
85/// because a newer `tix` writing one extra field must not make the definitions
86/// unreadable.
87pub const VERSION: u32 = 1;
88
89/// The key names reserved for **remote** export targets.
90///
91/// A view (or a scope, or a plan) carrying one of these keys cannot have it read
92/// as a remote namespace: remote export lives in `[export.*]` and `src/sync/`,
93/// and a portable view document only ever echoes it under
94/// [`PortableDocument::remote`].
95pub const REMOTE_TARGETS: &[&str] = &["gitlab", "linear"];
96
97/// Key names that denote executable or query-payload content.
98///
99/// The mission forbids arbitrary executable queries and new query languages
100/// (C-004/C-009). Nothing here is executed or parsed; the names exist only so a
101/// value under one of them is **reported as uninterpreted** rather than passing
102/// silently through a portable document.
103pub const EXECUTABLE_KEYS: &[&str] = &[
104    "code",
105    "command",
106    "dsl",
107    "exec",
108    "expr",
109    "expression",
110    "jq",
111    "program",
112    "query",
113    "run",
114    "script",
115    "shell",
116    "sql",
117];
118
119/// One view diagnostic, projected into the portable vocabulary.
120///
121/// The shape mirrors the surface DTO every `tix view` arm already returns
122/// (`kind`/`field`/`message`/`remedy`/`view_id`, with `null` for the parts a
123/// diagnostic does not have), so CLI, MCP, and portable output cannot drift
124/// (C-007/NFR-003). `kind` is the stable contract name — a plain string, so a
125/// kind a newer build adds survives a round trip instead of failing to parse.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct PortableDiagnostic {
128    /// The stable contract name of the diagnostic (`unknown_metadata`, …).
129    pub kind: String,
130    /// The view this is about, when it is about a stored definition.
131    #[serde(default)]
132    pub view_id: Option<String>,
133    /// The record-relative field the problem sits at (`extensions.colour`, …).
134    #[serde(default)]
135    pub field: Option<String>,
136    /// The human-readable explanation.
137    pub message: String,
138    /// What the caller can do about it, when there is something.
139    #[serde(default)]
140    pub remedy: Option<String>,
141}
142
143impl PortableDiagnostic {
144    /// Project a registry/plan diagnostic into the portable vocabulary.
145    #[must_use]
146    pub fn from_diagnostic(diagnostic: &ViewDiagnostic) -> Self {
147        Self {
148            kind: diagnostic.kind().as_str().to_string(),
149            view_id: diagnostic.view_id().map(|id| id.as_str().to_string()),
150            field: diagnostic.field().map(str::to_string),
151            message: diagnostic.message().to_string(),
152            remedy: diagnostic.remedy().map(str::to_string),
153        }
154    }
155
156    /// The stable contract name.
157    #[must_use]
158    pub fn kind(&self) -> &str {
159        &self.kind
160    }
161
162    /// The view this is about, when there is one.
163    #[must_use]
164    pub fn view_id(&self) -> Option<&str> {
165        self.view_id.as_deref()
166    }
167
168    /// The record-relative field, when the problem is field-shaped.
169    #[must_use]
170    pub fn field(&self) -> Option<&str> {
171        self.field.as_deref()
172    }
173
174    /// The human-readable explanation.
175    #[must_use]
176    pub fn message(&self) -> &str {
177        &self.message
178    }
179
180    /// The actionable part, when there is one.
181    #[must_use]
182    pub fn remedy(&self) -> Option<&str> {
183        self.remedy.as_deref()
184    }
185
186    /// One line, `kind [field]: message` — what a surface prints.
187    #[must_use]
188    pub fn render(&self) -> String {
189        match self.field() {
190            Some(field) => format!("{} [{field}]: {}", self.kind, self.message),
191            None => format!("{}: {}", self.kind, self.message),
192        }
193    }
194
195    /// Whether two diagnostics describe the same problem, which is how a derived
196    /// diagnostic is merged without being appended twice on a repeated round trip.
197    #[must_use]
198    fn same_as(&self, other: &Self) -> bool {
199        self.kind == other.kind
200            && self.view_id == other.view_id
201            && self.field == other.field
202            && self.message == other.message
203    }
204}
205
206/// The scope metadata of one portable view (data-model §2).
207///
208/// `kind` is a plain string rather than [`ViewScopeKind`](crate::views::registry::ViewScopeKind)
209/// on purpose: a scope family a newer build introduces must survive a round trip,
210/// and an enum would refuse the whole document instead. The projection mirrors the
211/// surface DTO's `scope` object exactly — the scope's preserved unknown fields
212/// live under `extensions`, never mixed into the scope's own keys.
213///
214/// [`ViewScopeKind`]: crate::views::registry::ViewScopeKind
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub struct PortableScope {
217    /// The scope family's contract name (`repository`, `project`, `user_share`).
218    pub kind: String,
219    /// The project a project scope belongs to.
220    #[serde(default)]
221    pub project_id: Option<String>,
222    /// The identity a user/share scope belongs to. A label, not an account.
223    #[serde(default)]
224    pub owner: Option<String>,
225    /// The identities a user/share scope is shared with. Labels, not grants.
226    #[serde(default)]
227    pub shared_with: Vec<String>,
228    /// Scope keys this build does not interpret, preserved verbatim.
229    #[serde(default)]
230    pub extensions: Table,
231    /// Scope keys a **newer** portable format adds, preserved verbatim.
232    #[serde(flatten)]
233    pub extra: Table,
234}
235
236impl PortableScope {
237    /// Project a registry scope into the portable form.
238    #[must_use]
239    pub fn from_scope(scope: &ViewScope) -> Self {
240        Self {
241            kind: scope.kind.as_str().to_string(),
242            project_id: scope.project_id.clone(),
243            owner: scope.owner.clone(),
244            shared_with: scope.shared_with.clone(),
245            extensions: scope.extensions.clone(),
246            extra: Table::new(),
247        }
248    }
249}
250
251/// One saved view in the portable form (data-model §1).
252///
253/// `plan` is the `[plan]` table **as written**, so a consumer can rebuild the
254/// stored definition byte for byte; `normalized_plan` is the same plan after
255/// [`ViewPlan::from_toml`]/[`ViewPlan::to_toml`], which is the one normalized plan
256/// every surface consumes (C-007) and is `None` exactly when this build cannot
257/// interpret the stored plan — in which case the refusal's diagnostics are carried
258/// in `diagnostics` and the `plan` table is still retained.
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct PortableView {
261    /// The stable identity. Immutable across rename; never reused.
262    pub id: String,
263    /// The display label. Mutable — it is not the identity.
264    pub name: String,
265    /// The explicit scope metadata.
266    pub scope: PortableScope,
267    /// The scope's derived resolution (`resolved`, `missing`, `conflict`).
268    pub resolution: String,
269    /// The persisted `[plan]` table, verbatim.
270    #[serde(default)]
271    pub plan: Table,
272    /// The normalized plan, when this build can interpret it.
273    #[serde(default)]
274    pub normalized_plan: Option<Table>,
275    /// Record-level fields this build does not interpret, preserved verbatim.
276    #[serde(default)]
277    pub extensions: Table,
278    /// Unknown-metadata, scope, namespace, and executable-content diagnostics.
279    #[serde(default)]
280    pub diagnostics: Vec<PortableDiagnostic>,
281    /// View keys a **newer** portable format adds, preserved verbatim.
282    #[serde(flatten)]
283    pub extra: Table,
284}
285
286impl PortableView {
287    /// Project one stored view, given the registry-level diagnostics the caller's
288    /// registry pass produced for it.
289    ///
290    /// The registry's own diagnostics are reused rather than recomputed, so the
291    /// scope/unknown-field vocabulary has exactly one author; the keys this module
292    /// classifies (a remote target name, an executable name) replace the generic
293    /// "unknown field" notice for the same field instead of duplicating it.
294    #[must_use]
295    pub fn from_saved<P: ProjectDirectory + ?Sized>(
296        view: &SavedView,
297        projects: &P,
298        registry_diagnostics: &[ViewDiagnostic],
299    ) -> Self {
300        let (normalized_plan, plan_diagnostics) = match ViewPlan::from_toml(&view.plan) {
301            Ok(plan) => (Some(plan.to_toml()), plan.diagnostics()),
302            Err(refusal) => (None, refusal.diagnostics().to_vec()),
303        };
304
305        let mut classified: Vec<String> = Vec::new();
306        let mut diagnostics: Vec<ViewDiagnostic> = Vec::new();
307        let tables = [
308            ("extensions", Some(&view.extensions)),
309            ("scope.extensions", Some(&view.scope.extensions)),
310            // These three spell the path the way `ViewPlan::diagnostics()` does
311            // (`plan.extensions.<key>`), so a classified key replaces the plan's
312            // own unknown-metadata diagnostic instead of being emitted twice.
313            ("plan.extensions", Some(&view.plan)),
314            (
315                "plan.filter.extensions",
316                view.plan.get("filter").and_then(Value::as_table),
317            ),
318            (
319                "plan.visibility.extensions",
320                view.plan.get("visibility").and_then(Value::as_table),
321            ),
322        ];
323        for (path, table) in tables {
324            let Some(table) = table else { continue };
325            for key in table.keys() {
326                let field = format!("{path}.{key}");
327                if let Some(diagnostic) = classify_key(&field, key, Some(&view.id)) {
328                    classified.push(field);
329                    diagnostics.push(diagnostic);
330                }
331            }
332        }
333        for diagnostic in registry_diagnostics.iter().chain(plan_diagnostics.iter()) {
334            let replaces_classified = diagnostic
335                .field()
336                .is_some_and(|field| classified.iter().any(|seen| seen.as_str() == field));
337            if !replaces_classified {
338                diagnostics.push(diagnostic.clone());
339            }
340        }
341
342        Self {
343            id: view.id.as_str().to_string(),
344            name: view.name.clone(),
345            scope: PortableScope::from_scope(&view.scope),
346            resolution: view.scope.resolution(projects).as_str().to_string(),
347            plan: view.plan.clone(),
348            normalized_plan,
349            extensions: view.extensions.clone(),
350            diagnostics: diagnostics
351                .iter()
352                .map(PortableDiagnostic::from_diagnostic)
353                .collect(),
354            extra: Table::new(),
355        }
356    }
357
358    /// The stable identity.
359    #[must_use]
360    pub fn id(&self) -> &str {
361        &self.id
362    }
363
364    /// The normalized plan, when this build can interpret it.
365    #[must_use]
366    pub fn normalized_plan(&self) -> Option<&Table> {
367        self.normalized_plan.as_ref()
368    }
369
370    /// Every diagnostic about this view.
371    #[must_use]
372    pub fn diagnostics(&self) -> &[PortableDiagnostic] {
373        &self.diagnostics
374    }
375}
376
377/// The active-view marker in the portable form (data-model §4).
378///
379/// Derived, informational provenance: it records which saved definition the live
380/// board was associated with, not what the live filters are. `applied_plan` is the
381/// normalized plan that was applied.
382#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
383pub struct PortableMarker {
384    /// The stable id of the selected definition.
385    pub view_id: String,
386    /// The surface the marker came from (`cli`, `tui`, `mcp`).
387    #[serde(default)]
388    pub source: Option<String>,
389    /// The optional display timestamp.
390    #[serde(default)]
391    pub applied_at: Option<String>,
392    /// The normalized plan that was applied.
393    #[serde(default)]
394    pub applied_plan: Table,
395    /// Marker keys a **newer** portable format adds, preserved verbatim.
396    #[serde(flatten)]
397    pub extra: Table,
398}
399
400impl PortableMarker {
401    /// Project a marker.
402    #[must_use]
403    pub fn from_marker(marker: &ActiveViewMarker) -> Self {
404        Self {
405            view_id: marker.view_id().as_str().to_string(),
406            source: marker.source().map(|surface| surface.as_str().to_string()),
407            applied_at: marker.applied_at().map(str::to_string),
408            applied_plan: marker.applied_plan().to_toml(),
409            extra: Table::new(),
410        }
411    }
412}
413
414/// One divergent field: its record-relative path and the saved/live values.
415///
416/// `saved`/`live` are `None` when the field is unset on that side, which is why
417/// they are serialized as `null` rather than omitted — "unset" is information.
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419pub struct PortableDifference {
420    /// The record-relative field path (`plan.sort`, `plan.visibility.hidden_columns`).
421    pub field: String,
422    /// The saved definition's normalized value, or `null` when unset.
423    #[serde(default)]
424    pub saved: Option<String>,
425    /// The live normalized value, or `null` when unset.
426    #[serde(default)]
427    pub live: Option<String>,
428}
429
430/// The read-only divergence report in the portable form (data-model §5).
431///
432/// A derived, per-operation value: it compares the saved plan with the live plan
433/// and never reconciles them. `in_sync` is false both when there are differences
434/// and when there are diagnostics ("nothing to compare" is not "in sync").
435#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
436pub struct PortableDivergence {
437    /// The view the comparison is about, when one was identified.
438    #[serde(default)]
439    pub view_id: Option<String>,
440    /// The saved definition's normalized plan, when it could be read.
441    #[serde(default)]
442    pub saved_plan: Option<Table>,
443    /// The live normalized plan that was compared.
444    #[serde(default)]
445    pub live_plan: Table,
446    /// The divergent fields, in a fixed field order.
447    #[serde(default)]
448    pub differences: Vec<PortableDifference>,
449    /// Whether the live plan matches the saved plan, with no diagnostics.
450    pub in_sync: bool,
451    /// Problems that prevented or qualified the comparison.
452    #[serde(default)]
453    pub diagnostics: Vec<PortableDiagnostic>,
454    /// Divergence keys a **newer** portable format adds, preserved verbatim.
455    #[serde(flatten)]
456    pub extra: Table,
457}
458
459impl PortableDivergence {
460    /// Project a divergence report.
461    #[must_use]
462    pub fn from_divergence(divergence: &ViewDivergence) -> Self {
463        Self {
464            view_id: divergence.view_id().map(|id| id.as_str().to_string()),
465            saved_plan: divergence.saved_plan().map(ViewPlan::to_toml),
466            live_plan: divergence.live_plan().to_toml(),
467            differences: divergence
468                .differences()
469                .iter()
470                .map(|difference| PortableDifference {
471                    field: difference.field().to_string(),
472                    saved: difference.saved().map(str::to_string),
473                    live: difference.live().map(str::to_string),
474                })
475                .collect(),
476            in_sync: divergence.in_sync(),
477            diagnostics: divergence
478                .diagnostics()
479                .iter()
480                .map(PortableDiagnostic::from_diagnostic)
481                .collect(),
482            extra: Table::new(),
483        }
484    }
485}
486
487/// One **remote** export target, echoed read-only (T037).
488///
489/// This is not view data and is never read as such: it carries the configured
490/// remote namespace's own non-secret fields so a consumer can tell the two apart.
491#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
492pub struct PortableRemoteTarget {
493    /// The target name (`gitlab`, `linear`).
494    pub name: String,
495    /// The target's configured fields, verbatim — **never** its token.
496    #[serde(default)]
497    pub fields: Table,
498    /// Target keys a **newer** portable format adds, preserved verbatim.
499    #[serde(flatten)]
500    pub extra: Table,
501}
502
503impl PortableRemoteTarget {
504    /// Project one remote target. The token is deliberately excluded: it is a
505    /// credential, `tix.toml` never writes it back, and a portable document must
506    /// not start doing so.
507    #[must_use]
508    pub fn new(name: &str, remote: &RemoteConfig) -> Self {
509        let mut fields = Table::new();
510        for (key, value) in [
511            ("base_url", &remote.base_url),
512            ("project", &remote.project),
513            ("team", &remote.team),
514        ] {
515            if let Some(value) = value {
516                fields.insert(key.to_string(), Value::String(value.clone()));
517            }
518        }
519        Self {
520            name: name.to_string(),
521            fields,
522            extra: Table::new(),
523        }
524    }
525
526    /// The target name.
527    #[must_use]
528    pub fn name(&self) -> &str {
529        &self.name
530    }
531}
532
533/// The remote-export namespace of a portable document (T037).
534///
535/// Always **separate** from [`PortableDocument::views`]: it is populated from
536/// `[export.*]` by the export surface and is empty for a document built only from
537/// a registry. A local view never contributes a target here, and a view key that
538/// happens to name a target is diagnosed instead (see [`classify_key`]).
539#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
540pub struct PortableRemoteTargets {
541    /// The configured remote targets, in `gitlab` then `linear` order.
542    #[serde(default)]
543    pub targets: Vec<PortableRemoteTarget>,
544    /// Namespace keys a **newer** portable format adds, preserved verbatim.
545    #[serde(flatten)]
546    pub extra: Table,
547}
548
549impl PortableRemoteTargets {
550    /// The configured targets, read out of `[export.*]` — never out of a view.
551    #[must_use]
552    pub fn from_config(config: &Config) -> Self {
553        let mut targets = Vec::new();
554        if let Some(gitlab) = &config.export.gitlab {
555            targets.push(PortableRemoteTarget::new("gitlab", gitlab));
556        }
557        if let Some(linear) = &config.export.linear {
558            targets.push(PortableRemoteTarget::new("linear", linear));
559        }
560        Self {
561            targets,
562            extra: Table::new(),
563        }
564    }
565
566    /// Whether no remote target is configured.
567    #[must_use]
568    pub fn is_empty(&self) -> bool {
569        self.targets.is_empty()
570    }
571
572    /// The configured targets.
573    #[must_use]
574    pub fn targets(&self) -> &[PortableRemoteTarget] {
575        &self.targets
576    }
577
578    /// Whether the namespace names `name` as a remote target.
579    #[must_use]
580    pub fn contains(&self, name: &str) -> bool {
581        self.targets.iter().any(|target| target.name == name)
582    }
583}
584
585/// The local portable saved-view document (T034).
586///
587/// Build one from a registry with [`PortableDocument::from_registry`], add the
588/// remote namespace with [`PortableDocument::with_remote`], and serialize it with
589/// [`PortableDocument::to_json`] / [`PortableDocument::from_json`]. All fields are
590/// public and always serialized — nothing is skipped — so a round trip is
591/// byte-stable by construction.
592#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
593pub struct PortableDocument {
594    /// The format marker: [`FORMAT`].
595    pub format: String,
596    /// The format version: [`VERSION`].
597    pub version: u32,
598    /// Every view, in registry declaration order.
599    #[serde(default)]
600    pub views: Vec<PortableView>,
601    /// The active marker, when one was projected.
602    #[serde(default)]
603    pub active: Option<PortableMarker>,
604    /// The divergence report, when one was projected.
605    #[serde(default)]
606    pub divergence: Option<PortableDivergence>,
607    /// The remote export namespace — separate from [`Self::views`] (T037).
608    #[serde(default)]
609    pub remote: PortableRemoteTargets,
610    /// Document-level keys this build does not interpret, preserved verbatim.
611    #[serde(flatten)]
612    pub extensions: Table,
613    /// Document-level diagnostics (format/version markers, unknown top-level keys).
614    #[serde(default)]
615    pub diagnostics: Vec<PortableDiagnostic>,
616}
617
618impl Default for PortableDocument {
619    fn default() -> Self {
620        Self::empty()
621    }
622}
623
624impl PortableDocument {
625    /// An empty document for this build's format version.
626    #[must_use]
627    pub fn empty() -> Self {
628        Self {
629            format: FORMAT.to_string(),
630            version: VERSION,
631            views: Vec::new(),
632            active: None,
633            divergence: None,
634            remote: PortableRemoteTargets::default(),
635            extensions: Table::new(),
636            diagnostics: Vec::new(),
637        }
638    }
639
640    /// Project a registry — and optionally an active marker and divergence report
641    /// — into the portable form (T034/T036).
642    ///
643    /// `projects` is the same project directory the registry's own resolution and
644    /// diagnostics use, so a project-scoped view is reported `missing` here exactly
645    /// as it is everywhere else. The remote namespace is left empty; the export
646    /// surface fills it from `[export.*]` (T037).
647    #[must_use]
648    pub fn from_registry<P: ProjectDirectory + ?Sized>(
649        registry: &ViewRegistry,
650        projects: &P,
651        active: Option<&ActiveViewMarker>,
652        divergence: Option<&ViewDivergence>,
653    ) -> Self {
654        let registry_diagnostics = registry.diagnostics(projects);
655        let views = registry
656            .iter()
657            .map(|view| {
658                let own: Vec<ViewDiagnostic> = registry_diagnostics
659                    .iter()
660                    .filter(|diagnostic| diagnostic.view_id() == Some(&view.id))
661                    .cloned()
662                    .collect();
663                PortableView::from_saved(view, projects, &own)
664            })
665            .collect();
666        let mut document = Self {
667            views,
668            active: active.map(PortableMarker::from_marker),
669            divergence: divergence.map(PortableDivergence::from_divergence),
670            ..Self::empty()
671        };
672        document.refresh_diagnostics();
673        document
674    }
675
676    /// Attach the remote export namespace read from `[export.*]`.
677    #[must_use]
678    pub fn with_remote(mut self, remote: PortableRemoteTargets) -> Self {
679        self.remote = remote;
680        self
681    }
682
683    /// The format marker as written.
684    #[must_use]
685    pub fn format(&self) -> &str {
686        &self.format
687    }
688
689    /// The format version as written.
690    #[must_use]
691    pub const fn version(&self) -> u32 {
692        self.version
693    }
694
695    /// Every view, in declaration order.
696    #[must_use]
697    pub fn views(&self) -> &[PortableView] {
698        &self.views
699    }
700
701    /// One view by stable id.
702    #[must_use]
703    pub fn view(&self, id: &str) -> Option<&PortableView> {
704        self.views.iter().find(|view| view.id == id)
705    }
706
707    /// The active marker, when one was projected.
708    #[must_use]
709    pub fn active(&self) -> Option<&PortableMarker> {
710        self.active.as_ref()
711    }
712
713    /// The divergence report, when one was projected.
714    #[must_use]
715    pub fn divergence(&self) -> Option<&PortableDivergence> {
716        self.divergence.as_ref()
717    }
718
719    /// The remote export namespace.
720    #[must_use]
721    pub const fn remote(&self) -> &PortableRemoteTargets {
722        &self.remote
723    }
724
725    /// Document-level unknown keys, preserved verbatim.
726    #[must_use]
727    pub const fn extensions(&self) -> &Table {
728        &self.extensions
729    }
730
731    /// Document-level diagnostics.
732    #[must_use]
733    pub fn diagnostics(&self) -> &[PortableDiagnostic] {
734        &self.diagnostics
735    }
736
737    /// Every diagnostic in the document: the document's own first, then each
738    /// view's in declaration order.
739    ///
740    /// What a surface reports. Unknown metadata is diagnosed *and* retained, so
741    /// this list is informational — it never changes what the document carries.
742    #[must_use]
743    pub fn all_diagnostics(&self) -> Vec<&PortableDiagnostic> {
744        let mut all: Vec<&PortableDiagnostic> = self.diagnostics.iter().collect();
745        for view in &self.views {
746            all.extend(view.diagnostics.iter());
747        }
748        all
749    }
750
751    /// Serialize the document as compact, byte-stable JSON.
752    pub fn to_json(&self) -> Result<String> {
753        serde_json::to_string(self)
754            .map_err(|error| TixError::Parse(format!("portable view document: {error}")))
755    }
756
757    /// Serialize the document as indented JSON — what `tix export --target views`
758    /// emits for a human.
759    pub fn to_json_pretty(&self) -> Result<String> {
760        serde_json::to_string_pretty(self)
761            .map_err(|error| TixError::Parse(format!("portable view document: {error}")))
762    }
763
764    /// Read a portable document, reporting (never dropping) anything it carries
765    /// that this build does not interpret.
766    pub fn from_json(text: &str) -> Result<Self> {
767        let mut document: Self = serde_json::from_str(text)
768            .map_err(|error| TixError::Parse(format!("portable view document: {error}")))?;
769        document.refresh_diagnostics();
770        Ok(document)
771    }
772
773    /// Merge the diagnostics this build derives from the document into the ones it
774    /// carried, without duplicating.
775    ///
776    /// Idempotent by design: `document → JSON → document → JSON` must be stable,
777    /// and a derived diagnostic that a previous round trip already stored is
778    /// *recognized* by [`PortableDiagnostic::same_as`] instead of appended a second
779    /// time. The document's own diagnostics keep their order; only genuinely new
780    /// derived ones are appended.
781    pub fn refresh_diagnostics(&mut self) {
782        let derived = self.derived_diagnostics();
783        let mut merged: Vec<PortableDiagnostic> =
784            Vec::with_capacity(self.diagnostics.len() + derived.len());
785        for diagnostic in self.diagnostics.iter().chain(derived.iter()) {
786            if !merged.iter().any(|seen| seen.same_as(diagnostic)) {
787                merged.push(diagnostic.clone());
788            }
789        }
790        self.diagnostics = merged;
791    }
792
793    /// The diagnostics the document itself implies: a foreign format/version
794    /// marker, and every unknown top-level key — including one that names a remote
795    /// target or looks executable, which get their specific report.
796    #[must_use]
797    fn derived_diagnostics(&self) -> Vec<PortableDiagnostic> {
798        let mut diagnostics: Vec<ViewDiagnostic> = Vec::new();
799        if self.format != FORMAT {
800            diagnostics.push(
801                ViewDiagnostic::new(
802                    ViewDiagnosticKind::UnknownMetadata,
803                    format!(
804                        "portable document declares format `{}`, but this build writes and reads \
805                         `{FORMAT}`; the declared value is preserved verbatim",
806                        self.format
807                    ),
808                )
809                .with_field("format")
810                .with_remedy(
811                    "read the document with the tool that wrote it, or migrate the format",
812                ),
813            );
814        }
815        if self.version != VERSION {
816            diagnostics.push(
817                ViewDiagnostic::new(
818                    ViewDiagnosticKind::UnknownMetadata,
819                    format!(
820                        "portable document declares version {}, but this build writes and reads \
821                         version {VERSION}; fields this build does not interpret are preserved \
822                         verbatim",
823                        self.version
824                    ),
825                )
826                .with_field("version"),
827            );
828        }
829        for key in self.extensions.keys() {
830            diagnostics.push(match classify_key(key, key, None) {
831                Some(diagnostic) => diagnostic,
832                None => unknown_metadata(key),
833            });
834        }
835        diagnostics
836            .iter()
837            .map(PortableDiagnostic::from_diagnostic)
838            .collect()
839    }
840}
841
842/// Classify one key by the shape it names: a remote export target, executable
843/// content, or nothing this module needs to say anything specific about.
844///
845/// `field` is the record-relative path the key sits at (`extensions.colour`,
846/// `plan.filter.query`); for a document-level key it is the key itself, because
847/// those keys are inline at the top level. `key` is the bare name being
848/// classified. Returns `None` for an ordinary unknown key, which the caller
849/// reports with the generic preserved-verbatim notice instead.
850fn classify_key(field: &str, key: &str, view_id: Option<&ViewId>) -> Option<ViewDiagnostic> {
851    let (message, remedy) = if REMOTE_TARGETS.contains(&key) {
852        (
853            format!(
854                "`{field}` names the remote export target `{key}`; remote namespaces are kept \
855                 separate from local view data and the value is preserved verbatim"
856            ),
857            "rename the key; remote export configuration lives under `[export.*]`, never on a view"
858                .to_string(),
859        )
860    } else if EXECUTABLE_KEYS.contains(&key) {
861        (
862            format!(
863                "`{field}` looks like executable or query-payload content; this build never \
864                 interprets it and preserves the value verbatim"
865            ),
866            "views are data-only; remove the key, or express the intent with a supported plan field"
867                .to_string(),
868        )
869    } else {
870        return None;
871    };
872    let diagnostic = match view_id {
873        Some(id) => ViewDiagnostic::for_view(ViewDiagnosticKind::UnknownMetadata, id, message),
874        None => ViewDiagnostic::new(ViewDiagnosticKind::UnknownMetadata, message),
875    };
876    Some(diagnostic.with_field(field.to_string()).with_remedy(remedy))
877}
878
879/// The generic preserved-verbatim notice for an unknown document-level key.
880fn unknown_metadata(field: &str) -> ViewDiagnostic {
881    ViewDiagnostic::new(
882        ViewDiagnosticKind::UnknownMetadata,
883        format!("`{field}` is not interpreted by this build; the value is preserved verbatim"),
884    )
885    .with_field(field.to_string())
886}