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}