- 156
Error, - 157
Unavailable, - 158
Stale, - 159
Map, - 160
Calendar, - 161
Board, - 162
Graph, - 163
Entity, - 164
Evidence, - 165
Form, - 166
Transaction, - 167
Alert, - 168
Conversation, - 169
Simulation, - 170
/// Ingredients, timed steps, yields and equipment. Table/KeyValue cannot - 171
/// express the ingredient/step/timer triple without the surface guessing - 172
/// which column means what, so this is a distinct concept, not a shortcut. - 173
Recipe, - 174
IngredientList, - 175
StepList, - 176
/// A question with takeaways, sources and citations bound together. The - 177
/// citation-to-takeaway relationship is lost if this is flattened into a - 178
/// Section plus a CitationList. - 179
Research, - 180
TakeawayList, - 181
SourceList, - 182
/// A sandboxed preview of authored UI. The isolation contract (no ambient - 183
/// privileges for the previewed document) is part of the primitive, which - 184
/// no composition of existing primitives carries. - 185
UiPreview, - 186
} - 187
- 188
// Deliberately absent: `MetricGrid`, `Media` and `UniversalCard`. Each is a - 189
// composition of primitives that already exist, and `SpecNode` lets any - 190
// primitive nest any other, so they are expressible today without growing the - 191
// vocabulary: - 192
// metric_grid = `Row`/`Section` whose children (or `each`/`item`) are `Metric` - 193
// media = `Image` / `Audio` / `Video` / `File` / `Gallery` - 194
// universal_card = `Entity` or `Section` containing `KeyValue` rows - 195
// A client-side rendering shortcut is not a reason for a host primitive; only a - 196
// concept that no composition can express is (see `Recipe`/`Research`/`UiPreview`). - 197
- 198
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 199
pub struct RenderTree { - 200
pub schema_version: u16, - 201
pub spec_id: String, - 202
pub revision: u64, - 203
pub digest: String, - 204
pub root: RenderNode, - 205
#[serde(default)] - 206
pub accessibility_summary: Option<String>, - 207
pub coverage: Coverage, - 208
} - 209
- 210
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 211
pub struct RenderNode { - 212
pub primitive: Primitive, - 213
#[serde(default)] - 214
pub props: BTreeMap<String, Value>, - 215
#[serde(default)] - 216
pub children: Vec<RenderNode>, - 217
} - 218
- 219
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] - 220
pub struct Coverage { - 221
pub rendered_paths: Vec<String>, - 222
pub omitted_paths: Vec<String>, - 223
} - 224
- 225
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 226
pub struct CompileInput { - 227
pub semantic_type: String, - 228
pub payload: Value, - 229
#[serde(default)] - 230
pub fallback_text: String, - 231
} - 232
- 233
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 234
pub enum CompiledPresentation { - 235
Rich(RenderTree), - 236
Fallback { text: String, reason: String }, - 237
} - 238
- 239
/// Trust boundary for reusable presentation definitions. User and workspace - 240
/// scopes are intentionally explicit so a pack cannot silently become global. - 241
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 242
#[serde(rename_all = "lowercase")] - 243
pub enum LibraryScope { - 244
#[serde(alias = "User")] - 245
User, - 246
#[serde(alias = "Workspace")] - 247
Workspace, - 248
} - 249
- 250
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 251
pub struct PresentationOrigin { - 252
pub scope: LibraryScope, - 253
pub owner: String, - 254
#[serde(default)] - 255
pub plugin_id: Option<String>, - 256
#[serde(default)] - 257
pub generation: Option<String>, - 258
} - 259
- 260
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 261
pub struct StoredPresentation { - 262
pub spec: PresentationSpec, - 263
pub digest: String, - 264
pub origin: PresentationOrigin, - 265
pub enabled: bool, - 266
} - 267
- 268
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 269
pub struct PresentationActivation { - 270
pub spec_id: String, - 271
pub revision: u64, - 272
pub scope: LibraryScope, - 273
pub owner: String, - 274
pub semantic_types: Vec<String>, - 275
} - 276
- 277
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 278
pub struct PresentationSuppression { - 279
pub spec_id: String, - 280
pub scope: LibraryScope, - 281
pub owner: String, - 282
} - 283
- 284
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 285
pub struct PresentationPackManifest { - 286
pub schema_version: u16, - 287
pub pack_id: String, - 288
pub version: String, - 289
pub digest: String, - 290
pub specs: Vec<String>, - 291
pub primitives: Vec<String>, - 292
#[serde(default)] - 293
pub publisher: Option<String>, - 294
} - 295
- 296
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] - 297
pub struct PresentationLibrary { - 298
specs: BTreeMap<(String, u64), StoredPresentation>, - 299
activations: Vec<PresentationActivation>, - 300
#[serde(default)] - 301
suppressions: Vec<PresentationSuppression>, - 302
} - 303
- 304
impl PresentationLibrary { - 305
/// Remove all definitions contributed by one plugin generation and any - 306
/// activation pointers that reference them. Historical session records are - 307
/// intentionally outside this projection and remain untouched. - 308
pub fn revoke_plugin(&mut self, plugin_id: &str) -> usize { - 309
let removed: BTreeSet<String> = self - 310
.specs - 311
.values() - 312
.filter(|stored| stored.origin.plugin_id.as_deref() == Some(plugin_id)) - 313
.map(|stored| stored.spec.id.clone()) - 314
.collect(); - 315
let before = self.specs.len(); - 316
self.specs - 317
.retain(|_, stored| stored.origin.plugin_id.as_deref() != Some(plugin_id)); - 318
self.activations - 319
.retain(|entry| !removed.contains(&entry.spec_id)); - 320
before - self.specs.len() - 321
} - 322
- 323
/// Freeze a validated candidate revision and register it without changing - 324
/// the currently active revision. Activation remains an explicit follow-up - 325
/// so previewing a proposal is always non-destructive. - 326
pub fn register_revision( - 327
&mut self, - 328
request: PresentationRevisionRequest, - 329
proposed: PresentationSpec, - 330
origin: PresentationOrigin, - 331
) -> Result<PresentationRevision, PresentationError> { - 332
let revision = propose_revision(request, proposed)?; - 333
self.register(StoredPresentation { - 334
spec: revision.proposed.clone(), - 335
digest: revision.digest.clone(), - 336
origin, - 337
enabled: false, - 338
})?; - 339
Ok(revision) - 340
} - 341
- 342
pub fn register(&mut self, stored: StoredPresentation) -> Result<(), PresentationError> { - 343
validate_spec(&stored.spec)?; - 344
let calculated = digest(&stored.spec)?; - 345
if stored.digest != calculated { - 346
return Err(PresentationError::DigestMismatch); - 347
} - 348
if stored.origin.owner.trim().is_empty() { - 349
return Err(PresentationError::InvalidSpec( - 350
"origin owner is empty".into(), - 351
)); - 352
} - 353
let key = (stored.spec.id.clone(), stored.spec.revision); - 354
if let Some(existing) = self.specs.get(&key) { - 355
if existing.digest != stored.digest { - 356
if stored.origin.owner == "builtin" && existing.origin.owner == "builtin" { - 357
self.specs.insert(key, stored); - 358
return Ok(()); - 359
} - 360
return Err(PresentationError::RevisionConflict(stored.spec.id)); - 361
} - 362
return Ok(()); - 363
} - 364
self.specs.insert(key, stored); - 365
Ok(()) - 366
} - 367
- 368
pub fn get(&self, id: &str, revision: u64) -> Option<&StoredPresentation> { - 369
self.specs.get(&(id.to_owned(), revision)) - 370
} - 371
- 372
pub fn activate( - 373
&mut self, - 374
id: &str, - 375
revision: u64, - 376
scope: LibraryScope, - 377
owner: &str, - 378
) -> Result<PresentationActivation, PresentationError> { - 379
let stored = self - 380
.get(id, revision) - 381
.ok_or_else(|| PresentationError::UnknownPresentation(id.to_owned()))?; - 382
let built_in = stored.origin.plugin_id.is_none() && stored.origin.owner == "builtin"; - 383
if !built_in && (stored.origin.scope != scope || stored.origin.owner != owner) { - 384
return Err(PresentationError::ActivationDenied); - 385
} - 386
let activation = PresentationActivation { - 387
spec_id: id.to_owned(), - 388
revision, - 389
scope, - 390
owner: owner.to_owned(), - 391
semantic_types: stored.spec.accepts.clone(), - 392
}; - 393
self.clear_suppression(id, scope, owner); - 394
if let Some(existing) = self - 395
.activations - 396
.iter_mut() - 397
.find(|entry| entry.spec_id == id && entry.scope == scope && entry.owner == owner) - 398
{ - 399
*existing = activation.clone(); - 400
} else { - 401
self.activations.push(activation.clone()); - 402
} - 403
if let Some(spec) = self.specs.get_mut(&(id.to_owned(), revision)) { - 404
spec.enabled = true; - 405
} - 406
Ok(activation) - 407
} - 408
- 409
pub fn deactivate(&mut self, id: &str, scope: LibraryScope, owner: &str) { - 410
self.activations - 411
.retain(|entry| !(entry.spec_id == id && entry.scope == scope && entry.owner == owner)); - 412
if !self.is_suppressed(id, scope, owner) { - 413
self.suppressions.push(PresentationSuppression { - 414
spec_id: id.to_owned(), - 415
scope, - 416
owner: owner.to_owned(), - 417
}); - 418
} - 419
} - 420
- 421
pub fn is_suppressed(&self, id: &str, scope: LibraryScope, owner: &str) -> bool { - 422
self.suppressions - 423
.iter() - 424
.any(|entry| entry.spec_id == id && entry.scope == scope && entry.owner == owner) - 425
} - 426
- 427
pub fn clear_suppression(&mut self, id: &str, scope: LibraryScope, owner: &str) { - 428
self.suppressions - 429
.retain(|entry| !(entry.spec_id == id && entry.scope == scope && entry.owner == owner)); - 430
} - 431
- 432
pub fn suppressions(&self) -> &[PresentationSuppression] { - 433
&self.suppressions - 434
} - 435
- 436
/// Restore the immutable original revision for a scope, or clear its - 437
/// activation when no original revision exists. Historical definitions - 438
/// and receipts remain untouched. - 439
pub fn reset(&mut self, id: &str, scope: LibraryScope, owner: &str) -> bool { - 440
self.deactivate(id, scope, owner); - 441
self.clear_suppression(id, scope, owner); - 442
let Some(original) = self.get(id, 1) else { - 443
return false; - 444
}; - 445
let built_in = original.origin.plugin_id.is_none() && original.origin.owner == "builtin"; - 446
if !built_in && (original.origin.scope != scope || original.origin.owner != owner) { - 447
return false; - 448
} - 449
self.activate(id, 1, scope, owner).is_ok() - 450
} - 451
- 452
pub fn select( - 453
&self, - 454
semantic_type: &str, - 455
scope: LibraryScope, - 456
owner: &str, - 457
) -> Option<&StoredPresentation> { - 458
self.activations - 459
.iter() - 460
.filter(|entry| entry.scope == scope && entry.owner == owner) - 461
.filter(|entry| { - 462
entry - 463
.semantic_types - 464
.iter() - 465
.any(|kind| kind == semantic_type) - 466
}) - 467
.filter_map(|entry| self.get(&entry.spec_id, entry.revision)) - 468
.filter(|stored| stored.enabled) - 469
.max_by_key(|stored| stored.spec.revision) - 470
} - 471
- 472
/// Resolve the narrowest explicit user intent before workspace intent. - 473
/// No effective values are copied between scopes; the returned definition - 474
/// remains owned by the layer that activated it. - 475
pub fn select_preferred( - 476
&self, - 477
semantic_type: &str, - 478
user_owner: &str, - 479
workspace_owner: &str, - 480
) -> Option<&StoredPresentation> { - 481
self.select(semantic_type, LibraryScope::User, user_owner) - 482
.or_else(|| self.select(semantic_type, LibraryScope::Workspace, workspace_owner)) - 483
} - 484
- 485
pub fn activations(&self) -> &[PresentationActivation] { - 486
&self.activations - 487
} - 488
- 489
pub fn definitions(&self) -> impl Iterator<Item = &StoredPresentation> { - 490
self.specs.values() - 491
} - 492
- 493
pub fn from_parts( - 494
definitions: impl IntoIterator<Item = StoredPresentation>, - 495
activations: Vec<PresentationActivation>, - 496
) -> Result<Self, PresentationError> { - 497
Self::from_parts_with_suppressions(definitions, activations, Vec::new()) - 498
} - 499
- 500
pub fn from_parts_with_suppressions( - 501
definitions: impl IntoIterator<Item = StoredPresentation>, - 502
activations: Vec<PresentationActivation>, - 503
suppressions: Vec<PresentationSuppression>, - 504
) -> Result<Self, PresentationError> { - 505
let mut library = Self { - 506
specs: BTreeMap::new(), - 507
activations, - 508
suppressions, - 509
}; - 510
for definition in definitions { - 511
library.register(definition)?; - 512
} - 513
Ok(library) - 514
} - 515
- 516
pub fn pack_manifest( - 517
&self, - 518
pack_id: impl Into<String>, - 519
version: impl Into<String>, - 520
primitives: Vec<String>, - 521
publisher: Option<String>, - 522
) -> Result<PresentationPackManifest, PresentationError> { - 523
let pack_id = pack_id.into(); - 524
let version = version.into(); - 525
let specs: Vec<String> = self - 526
.specs - 527
.values() - 528
.map(|stored| stored.digest.clone()) - 529
.collect(); - 530
let bytes = serde_json::to_vec(&(&pack_id, &version, &specs, &primitives, &publisher)) - 531
.map_err(|error| PresentationError::InvalidJson(error.to_string()))?; - 532
Ok(PresentationPackManifest { - 533
schema_version: SPEC_SCHEMA_VERSION, - 534
pack_id, - 535
version, - 536
digest: hex::encode(Sha256::digest(bytes)), - 537
specs, - 538
primitives, - 539
publisher, - 540
}) - 541
} - 542
} - 543
- 544
/// A user-directed revision request. The feedback string is audit context; - 545
/// only the already-authorized caller may turn a proposed spec into a stored - 546
/// revision. - 547
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 548
pub struct PresentationRevisionRequest { - 549
pub base_id: String, - 550
pub base_revision: u64, - 551
pub feedback: String, - 552
pub attempt: u8, - 553
} - 554
- 555
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 556
pub struct PresentationRevision { - 557
pub request: PresentationRevisionRequest, - 558
pub proposed: PresentationSpec, - 559
pub digest: String, - 560
} - 561
- 562
/// Validate and freeze one immutable candidate revision. The runtime, rather - 563
/// than the model, owns revision numbering and the retry ceiling. - 564
pub fn propose_revision( - 565
request: PresentationRevisionRequest, - 566
mut proposed: PresentationSpec, - 567
) -> Result<PresentationRevision, PresentationError> { - 568
if request.feedback.trim().is_empty() { - 569
return Err(PresentationError::InvalidSpec("feedback is empty".into())); - 570
} - 571
if request.attempt == 0 || request.attempt > 2 { - 572
return Err(PresentationError::RevisionBudgetExceeded); - 573
} - 574
if proposed.id != request.base_id { - 575
return Err(PresentationError::RevisionIdentityChanged); - 576
} - 577
let expected_revision = request - 578
.base_revision - 579
.checked_add(1) - 580
.ok_or(PresentationError::RevisionOverflow)?; - 581
if proposed.revision != expected_revision { - 582
proposed.revision = expected_revision; - 583
} - 584
validate_spec(&proposed)?; - 585
let digest = digest(&proposed)?; - 586
Ok(PresentationRevision { - 587
request, - 588
proposed, - 589
digest, - 590
}) - 591
} - 592
- 593
#[derive(Debug, Error, Clone, PartialEq, Eq)] - 594
pub enum PresentationError { - 595
#[error("presentation spec is not valid UTF-8 JSON: {0}")] - 596
InvalidJson(String), - 597
#[error("presentation spec exceeds {MAX_SPEC_BYTES} bytes")] - 598
SpecTooLarge, - 599
#[error("unsupported presentation schema version {0}")] - 600
UnsupportedSchema(u16), - 601
#[error("presentation spec is invalid: {0}")] - 602
InvalidSpec(String), - 603
#[error("binding is invalid: {0}")] - 604
InvalidBinding(String), - 605
#[error("required binding is missing: {0}")] - 606
MissingBinding(String), - 607
#[error("presentation result does not match semantic type {0}")] - 608
SemanticTypeMismatch(String), - 609
#[error("presentation limit exceeded: {0}")] - 610
Limit(String), - 611
#[error("presentation digest does not match its canonical spec")] - 612
DigestMismatch, - 613
#[error("presentation revision conflicts with an existing digest: {0}")] - 614
RevisionConflict(String), - 615
#[error("unknown presentation: {0}")] - 616
UnknownPresentation(String), - 617
#[error("presentation activation is outside its origin scope")] - 618
ActivationDenied, - 619
#[error("presentation revision retry budget exceeded")] - 620
RevisionBudgetExceeded, - 621
#[error("presentation revision changed its identity")] - 622
RevisionIdentityChanged, - 623
#[error("presentation revision number overflowed")] - 624
RevisionOverflow, - 625
} - 626
- 627
pub fn digest(spec: &PresentationSpec) -> Result<String, PresentationError> { - 628
let bytes = serde_json::to_vec(spec) - 629
.map_err(|error| PresentationError::InvalidJson(error.to_string()))?; - 630
Ok(hex::encode(Sha256::digest(bytes))) - 631
} - 632
- 633
pub fn parse_spec(bytes: &[u8]) -> Result<PresentationSpec, PresentationError> { - 634
if bytes.len() > MAX_SPEC_BYTES { - 635
return Err(PresentationError::SpecTooLarge); - 636
} - 637
let spec: PresentationSpec = serde_json::from_slice(bytes) - 638
.map_err(|error| PresentationError::InvalidJson(error.to_string()))?; - 639
validate_spec(&spec)?; - 640
Ok(spec) - 641
} - 642
- 643
pub fn validate_spec(spec: &PresentationSpec) -> Result<(), PresentationError> { - 644
if spec.schema_version != SPEC_SCHEMA_VERSION { - 645
return Err(PresentationError::UnsupportedSchema(spec.schema_version)); - 646
} - 647
if spec.id.trim().is_empty() || spec.id.len() > 256 { - 648
return Err(PresentationError::InvalidSpec( - 649
"id is empty or too long".into(), - 650
)); - 651
} - 652
if spec.revision == 0 { - 653
return Err(PresentationError::InvalidSpec( - 654
"revision must be positive".into(), - 655
)); - 656
} - 657
if spec.accepts.iter().any(|kind| kind.trim().is_empty()) { - 658
return Err(PresentationError::InvalidSpec( - 659
"accepts contains an empty type".into(), - 660
)); - 661
} - 662
let mut counters = Counters::default(); - 663
validate_node(&spec.root, 0, &mut counters)?; - 664
if let Some(summary) = &spec.accessibility.summary { - 665
validate_binding(summary)?; - 666
counters.bindings += 1; - 667
} - 668
if counters.bindings > MAX_BINDINGS { - 669
return Err(PresentationError::Limit("too many bindings".into())); - 670
} - 671
Ok(()) - 672
} - 673
- 674
#[derive(Default)] - 675
struct Counters { - 676
nodes: usize, - 677
bindings: usize, - 678
} - 679
- 680
fn validate_node( - 681
node: &SpecNode, - 682
depth: usize, - 683
counters: &mut Counters, - 684
) -> Result<(), PresentationError> { - 685
if depth > MAX_DEPTH { - 686
return Err(PresentationError::Limit( - 687
"maximum nesting depth exceeded".into(), - 688
)); - 689
} - 690
counters.nodes += 1; - 691
if counters.nodes > MAX_NODES { - 692
return Err(PresentationError::Limit("too many nodes".into())); - 693
} - 694
for value in node.props.values() { - 695
if let SpecValue::Text { value: text } = value - 696
&& text.len() > MAX_TEXT - 697
{ - 698
return Err(PresentationError::Limit("literal text is too long".into())); - 699
} - 700
if let SpecValue::Binding(binding) = value { - 701
validate_binding(binding)?; - 702
counters.bindings += 1; - 703
} - 704
} - 705
if let Some(each) = &node.each { - 706
validate_binding(each)?; - 707
counters.bindings += 1; - 708
if node.item.is_none() { - 709
return Err(PresentationError::InvalidSpec("each requires item".into())); - 710
} - 711
} - 712
for child in &node.children { - 713
validate_node(child, depth + 1, counters)?; - 714
} - 715
if let Some(item) = &node.item { - 716
validate_node(item, depth + 1, counters)?; - 717
} - 718
Ok(()) - 719
} - 720
- 721
fn validate_binding(binding: &Binding) -> Result<(), PresentationError> { - 722
if binding.path.len() > 512 || !binding.path.starts_with('$') { - 723
return Err(PresentationError::InvalidBinding(binding.path.clone())); - 724
} - 725
let mut chars = binding.path.chars().peekable(); - 726
let _ = chars.next(); - 727
while let Some(ch) = chars.next() { - 728
match ch { - 729
'.' => { - 730
let mut length = 0usize; - 731
while chars.peek().is_some_and(|value| { - 732
value.is_ascii_alphanumeric() || *value == '_' || *value == '-' - 733
}) { - 734
let _ = chars.next(); - 735
length += 1; - 736
} - 737
if length == 0 { - 738
return Err(PresentationError::InvalidBinding(binding.path.clone())); - 739
} - 740
} - 741
'[' => { - 742
let mut digits = 0usize; - 743
while chars.peek().is_some_and(|value| value.is_ascii_digit()) { - 744
let _ = chars.next(); - 745
digits += 1; - 746
} - 747
if digits == 0 || chars.next() != Some(']') { - 748
return Err(PresentationError::InvalidBinding(binding.path.clone())); - 749
} - 750
} - 751
_ => return Err(PresentationError::InvalidBinding(binding.path.clone())), - 752
} - 753
} - 754
Ok(()) - 755
} - 756
- 757
pub fn compile(spec: &PresentationSpec, input: &CompileInput) -> CompiledPresentation { - 758
match compile_rich(spec, input) { - 759
Ok(tree) => CompiledPresentation::Rich(tree), - 760
Err(error) => CompiledPresentation::Fallback { - 761
text: input.fallback_text.clone(), - 762
reason: error.to_string(), - 763
}, - 764
} - 765
} - 766
- 767
/// Infer a conservative presentation shape from an already-validated JSON - 768
/// payload. This is intentionally structural, not domain-aware: it never - 769
/// guesses "trip", "budget", or another scenario from prose or field names. - 770
/// Callers still validate the returned semantic type against their registry. - 771
pub fn infer_semantic_type(payload: &Value) -> Option<&'static str> { - 772
let object = payload.as_object()?; - 773
// These recognizers use payload shape rather than scenario vocabulary. A - 774
// caller can therefore reuse the same semantic contract for any domain or - 775
// extension pack without teaching the core about that domain. - 776
if object.get("left").is_some() && object.get("right").is_some() - 777
|| object.get("alternatives").is_some_and(Value::is_array) - 778
{ - 779
return Some("comparison"); - 780
} - 781
if object.get("checks").is_some_and(Value::is_array) - 782
|| object.get("tasks").is_some_and(Value::is_array) - 783
{ - 784
return Some("checklist"); - 785
} - 786
if object.get("events").is_some_and(Value::is_array) - 787
|| object.get("slots").is_some_and(Value::is_array) - 788
{ - 789
return Some("schedule"); - 790
} - 791
if object.get("agenda").is_some_and(Value::is_array) - 792
&& object.get("attendees").is_some_and(Value::is_array) - 793
{ - 794
return Some("meeting"); - 795
} - 796
if object.get("lessons").is_some_and(Value::is_array) - 797
|| object.get("sections").is_some_and(Value::is_array) - 798
{ - 799
return Some("lesson"); - 800
} - 801
if object.get("decision").is_some() || object.get("choice").is_some() { - 802
return Some("decision"); - 803
} - 804
if object.get("income").is_some_and(Value::is_array) - 805
&& object.get("expenses").is_some_and(Value::is_array) - 806
|| object.get("credits").is_some_and(Value::is_array) - 807
&& object.get("debits").is_some_and(Value::is_array) - 808
{ - 809
return Some("budget"); - 810
} - 811
if object.get("items").is_some_and(Value::is_array) - 812
|| object.get("entries").is_some_and(Value::is_array) - 813
{ - 814
return Some("collection"); - 815
} - 816
if object.get("steps").is_some_and(Value::is_array) { - 817
return Some("steps"); - 818
} - 819
if object.get("status").is_some() || object.get("state").is_some() { - 820
return Some("status"); - 821
} - 822
// Numeric measures are a deliberately structural shape. The field names - 823
// describe a value slot, not a business domain, so this remains reusable - 824
// for budgets, scores, counts, readings, and any future extension pack. - 825
if ["value", "amount", "count", "total"] - 826
.iter() - 827
.any(|key| object.get(*key).is_some_and(Value::is_number)) - 828
{ - 829
return Some("metric"); - 830
} - 831
if object.get("title").is_some() || object.get("summary").is_some() { - 832
return Some("detail"); - 833
} - 834
None - 835
} - 836
- 837
fn compile_rich( - 838
spec: &PresentationSpec, - 839
input: &CompileInput, - 840
) -> Result<RenderTree, PresentationError> { - 841
validate_spec(spec)?; - 842
if !spec.accepts.is_empty() && !spec.accepts.iter().any(|kind| kind == &input.semantic_type) { - 843
return Err(PresentationError::SemanticTypeMismatch( - 844
input.semantic_type.clone(), - 845
)); - 846
} - 847
let mut coverage = Coverage::default(); - 848
let root = compile_node(&spec.root, &input.payload, "$", &mut coverage)?; - 849
let accessibility_summary = spec - 850
.accessibility - 851
.summary - 852
.as_ref() - 853
.and_then(|binding| resolve_binding(&input.payload, binding).ok().flatten()) - 854
.and_then(value_as_text); - 855
Ok(RenderTree { - 856
schema_version: RENDER_TREE_SCHEMA_VERSION, - 857
spec_id: spec.id.clone(), - 858
revision: spec.revision, - 859
digest: digest(spec)?, - 860
root, - 861
accessibility_summary, - 862
coverage, - 863
}) - 864
} - 865
- 866
fn compile_node( - 867
node: &SpecNode, - 868
data: &Value, - 869
path: &str, - 870
coverage: &mut Coverage, - 871
) -> Result<RenderNode, PresentationError> { - 872
let mut children = node - 873
.children - 874
.iter() - 875
.enumerate() - 876
.map(|(index, child)| { - 877
compile_node(child, data, &format!("{path}.children[{index}]"), coverage) - 878
}) - 879
.collect::<Result<Vec<_>, _>>()?; - 880
if let Some(binding) = &node.each { - 881
let Some(value) = resolve_binding(data, binding)? else { - 882
if binding.required { - 883
return Err(PresentationError::MissingBinding(binding.path.clone())); - 884
} - 885
return Ok(RenderNode { - 886
primitive: node.primitive, - 887
props: BTreeMap::new(), - 888
children, - 889
}); - 890
}; - 891
let Some(items) = value.as_array() else { - 892
return Err(PresentationError::InvalidSpec(format!( - 893
"binding {} is not a list", - 894
binding.path - 895
))); - 896
}; - 897
if items.len() > MAX_EXPANSION_ITEMS { - 898
return Err(PresentationError::Limit( - 899
"runtime collection expansion exceeded".into(), - 900
)); - 901
} - 902
for (index, item) in items.iter().enumerate() { - 903
let Some(template) = node.item.as_ref() else { - 904
return Err(PresentationError::InvalidSpec("each requires item".into())); - 905
}; - 906
children.push(compile_node( - 907
template, - 908
item, - 909
&format!("{path}[{index}]"), - 910
coverage, - 911
)?); - 912
} - 913
coverage.rendered_paths.push(binding.path.clone()); - 914
} - 915
let mut props = BTreeMap::new(); - 916
for (key, value) in &node.props { - 917
match resolve_spec_value(value, data)? { - 918
Some(resolved) => { - 919
if key == "*" { - 920
let Value::Object(values) = resolved else { - 921
return Err(PresentationError::InvalidSpec( - 922
"spread binding is not an object".into(), - 923
)); - 924
}; - 925
props.extend(values); - 926
} else { - 927
props.insert(key.clone(), resolved); - 928
} - 929
coverage.rendered_paths.push(format!("{path}.{key}")); - 930
} - 931
None => coverage.omitted_paths.push(format!("{path}.{key}")), - 932
} - 933
} - 934
Ok(RenderNode { - 935
primitive: node.primitive, - 936
props, - 937
children, - 938
}) - 939
} - 940
- 941
fn resolve_spec_value(value: &SpecValue, data: &Value) -> Result<Option<Value>, PresentationError> { - 942
match value { - 943
SpecValue::Text { value: text } => Ok(Some(Value::String(text.clone()))), - 944
SpecValue::Number { value: number } => serde_json::Number::from_f64(*number) - 945
.map(Value::Number) - 946
.map(Some) - 947
.ok_or_else(|| PresentationError::InvalidSpec("non-finite number".into())), - 948
SpecValue::Boolean { value } => Ok(Some(Value::Bool(*value))), - 949
SpecValue::Literal { value } => { - 950
if value.to_string().len() > MAX_TEXT { - 951
return Err(PresentationError::Limit( - 952
"literal value is too large".into(), - 953
)); - 954
} - 955
Ok(Some(value.clone())) - 956
} - 957
SpecValue::Binding(binding) => resolve_binding(data, binding), - 958
} - 959
} - 960
- 961
fn resolve_binding(data: &Value, binding: &Binding) -> Result<Option<Value>, PresentationError> { - 962
validate_binding(binding)?; - 963
let mut current = data; - 964
if binding.path != "$" { - 965
let mut chars = binding.path.chars().peekable(); - 966
let _ = chars.next(); - 967
while let Some(ch) = chars.next() { - 968
match ch { - 969
'.' => { - 970
let mut key = String::new(); - 971
while chars.peek().is_some_and(|value| { - 972
value.is_ascii_alphanumeric() || *value == '_' || *value == '-' - 973
}) { - 974
if let Some(value) = chars.next() { - 975
key.push(value); - 976
} - 977
} - 978
current = match current.get(&key) { - 979
Some(value) => value, - 980
None => return missing_binding(binding), - 981
}; - 982
} - 983
'[' => { - 984
let mut digits = String::new(); - 985
while chars.peek().is_some_and(|value| value.is_ascii_digit()) { - 986
if let Some(value) = chars.next() { - 987
digits.push(value); - 988
} - 989
} - 990
let _ = chars.next(); - 991
let index = digits - 992
.parse::<usize>() - 993
.map_err(|_| PresentationError::InvalidBinding(binding.path.clone()))?; - 994
current = match current.get(index) { - 995
Some(value) => value, - 996
None => return missing_binding(binding), - 997
}; - 998
} - 999
_ => return Err(PresentationError::InvalidBinding(binding.path.clone())), - 1000
} - 1001
} - 1002
} - 1003
Ok(Some(current.clone())) - 1004
} - 1005
- 1006
fn missing_binding(binding: &Binding) -> Result<Option<Value>, PresentationError> { - 1007
if binding.required { - 1008
return Err(PresentationError::MissingBinding(binding.path.clone())); - 1009
} - 1010
Ok(match binding.empty { - 1011
EmptyValue::Omit => None, - 1012
EmptyValue::EmptyText => Some(Value::String(String::new())), - 1013
EmptyValue::EmptyList => Some(Value::Array(Vec::new())), - 1014
}) - 1015
} - 1016
- 1017
fn value_as_text(value: Value) -> Option<String> { - 1018
match value { - 1019
Value::String(text) => Some(text), - 1020
Value::Number(number) => Some(number.to_string()), - 1021
Value::Bool(value) => Some(value.to_string()), - 1022
_ => None, - 1023
} - 1024
} - 1025
- 1026
#[cfg(test)] - 1027
#[allow(clippy::expect_used, clippy::unwrap_used)] - 1028
mod tests { - 1029
use super::*; - 1030
- 1031
/// A pack registered at runtime may compose the existing primitive - 1032
/// vocabulary in new ways with no code change: `metric_grid`, `media` and - 1033
/// `universal_card` are specs, not primitives. This deserializes such a - 1034
/// pack from JSON exactly as a plugin would register it. - 1035
#[test] - 1036
fn runtime_registered_pack_composes_existing_primitives() { - 1037
for (semantic_type, root) in [ - 1038
( - 1039
"metrics.grid", - 1040
serde_json::json!({ - 1041
"primitive": "row", - 1042
"each": { "path": "$.metrics" }, - 1043
"item": { - 1044
"primitive": "metric", - 1045
"props": { - 1046
"label": { "kind": "binding", "path": "$.label" }, - 1047
"value": { "kind": "binding", "path": "$.value" } - 1048
} - 1049
} - 1050
}), - 1051
), - 1052
( - 1053
"media.gallery", - 1054
serde_json::json!({ - 1055
"primitive": "gallery", - 1056
"children": [ - 1057
{ "primitive": "image", "props": { "src": { "kind": "binding", "path": "$.src" } } }, - 1058
{ "primitive": "video", "props": { "src": { "kind": "binding", "path": "$.clip" } } } - 1059
] - 1060
}), - 1061
), - 1062
( - 1063
"card.universal", - 1064
serde_json::json!({ - 1065
"primitive": "entity", - 1066
"props": { "title": { "kind": "binding", "path": "$.title" } }, - 1067
"children": [{ - 1068
"primitive": "key_value", - 1069
"props": { - 1070
"label": { "kind": "binding", "path": "$.label" }, - 1071
"value": { "kind": "binding", "path": "$.value" } - 1072
} - 1073
}] - 1074
}), - 1075
), - 1076
] { - 1077
let spec: PresentationSpec = serde_json::from_value(serde_json::json!({ - 1078
"schema_version": SPEC_SCHEMA_VERSION, - 1079
"id": format!("plugin.{semantic_type}"), - 1080
"revision": 1, - 1081
"accepts": [semantic_type], - 1082
"root": root, - 1083
})) - 1084
.expect("runtime pack parses"); - 1085
validate_spec(&spec).expect("runtime pack validates"); - 1086
let result = compile( - 1087
&spec, - 1088
&CompileInput { - 1089
semantic_type: semantic_type.into(), - 1090
payload: serde_json::json!({ - 1091
"title": "Card", - 1092
"label": "Latency", - 1093
"value": "12ms", - 1094
"src": "a.png", - 1095
"clip": "a.mp4", - 1096
"metrics": [{"label":"p50","value":"9ms"},{"label":"p99","value":"40ms"}], - 1097
}), - 1098
fallback_text: "Fallback".into(), - 1099
}, - 1100
); - 1101
assert!( - 1102
matches!(result, CompiledPresentation::Rich(_)), - 1103
"{semantic_type} should compile without any code change: {result:?}" - 1104
); - 1105
} - 1106
} - 1107
- 1108
fn spec() -> PresentationSpec { - 1109
PresentationSpec { - 1110
schema_version: SPEC_SCHEMA_VERSION, - 1111
id: "test.timeline".into(), - 1112
revision: 1, - 1113
accepts: vec!["trip".into()], - 1114
root: SpecNode { - 1115
primitive: Primitive::Timeline, - 1116
props: BTreeMap::from([( - 1117
String::from("title"), - 1118
SpecValue::Binding(Binding { - 1119
path: "$.title".into(), - 1120
required: true, - 1121
empty: EmptyValue::Omit, - 1122
}), - 1123
)]), - 1124
children: Vec::new(), - 1125
each: Some(Binding { - 1126
path: "$.days".into(), - 1127
required: true, - 1128
empty: EmptyValue::Omit, - 1129
}), - 1130
item: Some(Box::new(SpecNode { - 1131
primitive: Primitive::Section, - 1132
props: BTreeMap::from([( - 1133
String::from("label"), - 1134
SpecValue::Binding(Binding { - 1135
path: "$.label".into(), - 1136
required: true, - 1137
empty: EmptyValue::Omit, - 1138
}), - 1139
)]), - 1140
children: Vec::new(), - 1141
each: None, - 1142
item: None, - 1143
})), - 1144
}, - 1145
fallback: FallbackSpec::default(), - 1146
accessibility: AccessibilitySpec { - 1147
summary: Some(Binding { - 1148
path: "$.summary".into(), - 1149
required: false, - 1150
empty: EmptyValue::EmptyText, - 1151
}), - 1152
}, - 1153
metadata: BTreeMap::new(), - 1154
} - 1155
}
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.