1use std::collections::{HashMap, HashSet};
39use std::sync::OnceLock;
40
41use serde::{Deserialize, Serialize};
42
43pub const MANIFEST_DATA: &str = include_str!("data/manifest.json");
45
46pub const PROBES_DATA: &str = include_str!("probes/probes.json");
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Reference {
52 pub name: String,
53 pub version: String,
54 #[serde(rename = "contentCommit")]
55 pub content_commit: String,
56 pub integrity: String,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct Provenance {
62 pub generator: String,
63 pub license: String,
64 pub reviewed: bool,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub enum FunctionKind {
71 Action,
73 Value,
75 MemberAction,
77 MemberValue,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
86#[serde(rename_all = "kebab-case")]
87pub enum CatalogLink {
88 #[default]
89 Canonical,
90 SpecialLowering,
91 LegacyAlias,
92 CatalogGap,
93}
94
95impl FunctionKind {
96 pub fn is_action(self) -> bool {
98 matches!(self, FunctionKind::Action | FunctionKind::MemberAction)
99 }
100
101 pub fn is_value(self) -> bool {
103 matches!(self, FunctionKind::Value | FunctionKind::MemberValue)
104 }
105
106 pub fn is_member(self) -> bool {
108 matches!(self, FunctionKind::MemberAction | FunctionKind::MemberValue)
109 }
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "PascalCase")]
123pub enum ReceiverCategory {
124 Player,
125 Variable,
126 String,
127 Vector,
128 Any,
129}
130
131impl ReceiverCategory {
132 pub fn describe(self) -> &'static str {
134 match self {
135 ReceiverCategory::Player => "a player-valued expression",
136 ReceiverCategory::Variable => "an assignable variable",
137 ReceiverCategory::String => "a string literal",
138 ReceiverCategory::Vector => "a vector-valued expression",
139 ReceiverCategory::Any => "any expression",
140 }
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148#[serde(untagged)]
149pub enum ParamDefault {
150 Call {
151 call: String,
152 #[serde(default)]
153 args: Vec<f64>,
154 },
155 Null {
156 null: bool,
157 },
158 EnumMember(String),
159 Bool(bool),
160 Number(f64),
161}
162
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166pub struct Param {
167 pub name: String,
168 #[serde(default)]
170 pub domain: Option<String>,
171 #[serde(default)]
173 pub default: Option<ParamDefault>,
174 #[serde(default)]
177 pub optional: bool,
178 #[serde(default)]
182 pub keyword_only: bool,
183 #[serde(default)]
187 pub positional_only: bool,
188 #[serde(default)]
192 pub alternate_names: Vec<String>,
193 #[serde(default)]
199 pub variable: bool,
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct Function {
206 pub id: String,
207 pub kind: FunctionKind,
208 #[serde(default)]
211 pub receiver: Option<ReceiverCategory>,
212 #[serde(default)]
213 pub params: Vec<Param>,
214 #[serde(default)]
216 pub unbounded: bool,
217 #[serde(default = "default_keyword_args")]
223 pub keyword_args: bool,
224 #[serde(default)]
227 #[serde(rename = "catalogId")]
228 pub catalog_id: Option<String>,
229 #[serde(default)]
231 pub catalog_link: CatalogLink,
232 #[serde(default)]
234 pub evidence: Vec<String>,
235}
236
237impl Function {
238 pub fn arity_bounds(&self) -> (usize, Option<usize>) {
242 if self.unbounded {
243 return (0, None);
244 }
245 let min = self
246 .params
247 .iter()
248 .rposition(|param| param.default.is_none() && !param.optional)
249 .map_or(0, |index| index + 1);
250 (min, Some(self.params.len()))
251 }
252}
253
254fn default_keyword_args() -> bool {
255 true
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "camelCase")]
261pub struct Alias {
262 pub source: String,
263 pub target: String,
264 pub kind: AliasKind,
265 #[serde(default)]
266 pub evidence: Vec<String>,
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "camelCase")]
273pub enum AliasKind {
274 FunctionAlias,
275 MemberAlias,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase")]
281pub struct Probe {
282 pub id: String,
283 pub source: String,
284 pub sha256: String,
285 pub expect: String,
286 #[serde(default)]
287 pub output_sha256: Option<String>,
288 #[serde(default)]
289 pub diagnostic_contains: Option<String>,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct ManifestError(pub String);
295
296impl std::fmt::Display for ManifestError {
297 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298 f.write_str(&self.0)
299 }
300}
301
302impl std::error::Error for ManifestError {}
303
304#[derive(Debug, Clone)]
318pub struct Manifest {
319 schema_version: u32,
320 reference: Reference,
321 functions: Vec<Function>,
322 aliases: Vec<Alias>,
323 provenance: Provenance,
324 probes: Vec<Probe>,
326 by_function: HashMap<String, usize>,
327 by_member: HashMap<String, usize>,
328 alias_by_source: HashMap<String, usize>,
329 domain_identities: HashSet<String>,
334}
335
336#[derive(Serialize, Deserialize)]
337#[serde(rename_all = "camelCase")]
338struct ManifestFile {
339 schema_version: u32,
340 reference: Reference,
341 #[serde(default)]
342 functions: Vec<Function>,
343 #[serde(default)]
344 aliases: Vec<Alias>,
345 provenance: Provenance,
346}
347
348#[derive(Serialize, Deserialize)]
349#[serde(rename_all = "camelCase")]
350struct ProbesFile {
351 schema_version: u32,
352 #[serde(default)]
353 probes: Vec<Probe>,
354}
355
356impl Manifest {
357 pub fn load(manifest_json: &str, probes_json: &str) -> Result<Manifest, ManifestError> {
359 let file: ManifestFile = serde_json::from_str(manifest_json)
360 .map_err(|error| ManifestError(format!("manifest data: {error}")))?;
361 if file.schema_version != 1 {
362 return Err(ManifestError(format!(
363 "unsupported manifest schemaVersion {}",
364 file.schema_version
365 )));
366 }
367 let probes_file: ProbesFile = serde_json::from_str(probes_json)
368 .map_err(|error| ManifestError(format!("probes data: {error}")))?;
369 if probes_file.schema_version != 1 {
370 return Err(ManifestError(format!(
371 "unsupported probes schemaVersion {}",
372 probes_file.schema_version
373 )));
374 }
375 let ManifestFile {
376 schema_version,
377 reference,
378 functions,
379 aliases,
380 provenance,
381 } = file;
382 let mut manifest = Manifest {
383 schema_version,
384 reference,
385 functions: Vec::with_capacity(functions.len()),
386 aliases: Vec::with_capacity(aliases.len()),
387 provenance,
388 probes: probes_file.probes,
389 by_function: HashMap::new(),
390 by_member: HashMap::new(),
391 alias_by_source: HashMap::new(),
392 domain_identities: HashSet::new(),
393 };
394 manifest.validate(functions, aliases)?;
395 Ok(manifest)
396 }
397
398 fn validate(
399 &mut self,
400 functions: Vec<Function>,
401 aliases: Vec<Alias>,
402 ) -> Result<(), ManifestError> {
403 let mut probes: HashMap<&str, &Probe> = HashMap::new();
406 for probe in &self.probes {
407 if probes.insert(&probe.id, probe).is_some() {
408 return Err(ManifestError(format!("duplicate probe id '{}'", probe.id)));
409 }
410 }
411
412 for function in functions {
416 if self.by_function.contains_key(&function.id) {
417 return Err(ManifestError(format!(
418 "duplicate function id '{}'",
419 function.id
420 )));
421 }
422 match function.kind {
423 FunctionKind::MemberAction | FunctionKind::MemberValue => {
424 if function.receiver.is_none() {
425 return Err(ManifestError(format!(
426 "member function '{}' declares no receiver category",
427 function.id
428 )));
429 }
430 }
431 FunctionKind::Action | FunctionKind::Value => {
432 if function.receiver.is_some() {
433 return Err(ManifestError(format!(
434 "non-member function '{}' declares a receiver category",
435 function.id
436 )));
437 }
438 }
439 }
440 for param in function.params.iter() {
441 if let Some(domain) = ¶m.domain {
442 let is_contextual = crate::lower::policy::is_contextual_domain(domain);
447 if !is_contextual {
448 self.domain_identities.insert(domain.clone());
449 }
450 } else if matches!(param.default, Some(ParamDefault::EnumMember(_))) {
451 return Err(ManifestError(format!(
452 "function '{}' parameter '{}' has an enum-member default but no \
453 declared domain",
454 function.id, param.name
455 )));
456 }
457 if param.keyword_only && param.positional_only {
458 return Err(ManifestError(format!(
459 "function '{}' parameter '{}' cannot be both keyword-only and \
460 positional-only",
461 function.id, param.name
462 )));
463 }
464 for alternate in ¶m.alternate_names {
465 if alternate == ¶m.name {
466 return Err(ManifestError(format!(
467 "function '{}' parameter '{}' repeats its name as an \
468 alternate keyword spelling",
469 function.id, param.name
470 )));
471 }
472 if function.params.iter().any(|other| {
473 !std::ptr::eq(other, param)
474 && (&other.name == alternate
475 || other.alternate_names.contains(alternate))
476 }) {
477 return Err(ManifestError(format!(
478 "function '{}' alternate keyword spelling '{alternate}' \
479 collides with another parameter",
480 function.id
481 )));
482 }
483 }
484 }
485 match (&function.catalog_id, function.catalog_link) {
486 (Some(_), CatalogLink::Canonical)
487 | (None, CatalogLink::SpecialLowering)
488 | (None, CatalogLink::LegacyAlias)
489 | (None, CatalogLink::CatalogGap) => {}
490 (Some(id), link) => {
491 return Err(ManifestError(format!(
492 "function '{}' has catalogId '{id}' but catalogLink is {:?}",
493 function.id, link
494 )));
495 }
496 (None, CatalogLink::Canonical) => {
497 return Err(ManifestError(format!(
498 "function '{}' has no catalogId or explicit catalogLink reason",
499 function.id
500 )));
501 }
502 }
503 crate::lower::policy::validate(&function).map_err(ManifestError)?;
504 if let Some(contextual) = crate::lower::policy::contextual_domain(&function.id) {
505 for option in contextual.options {
506 self.domain_identities.insert(option.domain.to_string());
507 }
508 }
509 self.check_evidence(&function.id, &function.evidence, &probes)?;
510 if function.kind.is_member() {
511 self.by_member
512 .insert(function.id.clone(), self.functions.len());
513 } else {
514 self.by_function
515 .insert(function.id.clone(), self.functions.len());
516 }
517 self.functions.push(function);
518 }
519
520 for alias in aliases {
523 if self.alias_by_source.contains_key(&alias.source) {
524 return Err(ManifestError(format!(
525 "duplicate alias source '{}'",
526 alias.source
527 )));
528 }
529 if self.by_function.contains_key(&alias.source)
530 || self.by_member.contains_key(&alias.source)
531 {
532 return Err(ManifestError(format!(
533 "alias source '{}' collides with a declared function",
534 alias.source
535 )));
536 }
537 match alias.kind {
538 AliasKind::FunctionAlias => {
539 if self.function(&alias.target).is_none() {
540 return Err(ManifestError(format!(
541 "alias '{}' targets '{}' which is not a generic function",
542 alias.source, alias.target
543 )));
544 }
545 }
546 AliasKind::MemberAlias => {
547 if self.member(&alias.target).is_none() {
548 return Err(ManifestError(format!(
549 "alias '{}' targets '{}' which is not a member function",
550 alias.source, alias.target
551 )));
552 }
553 }
554 }
555 self.check_evidence(&alias.source, &alias.evidence, &probes)?;
556 self.alias_by_source
557 .insert(alias.source.clone(), self.aliases.len());
558 self.aliases.push(alias);
559 }
560
561 Ok(())
562 }
563
564 fn check_evidence(
565 &self,
566 owner: &str,
567 evidence: &[String],
568 probes: &HashMap<&str, &Probe>,
569 ) -> Result<(), ManifestError> {
570 if evidence.is_empty() {
571 return Err(ManifestError(format!(
572 "entry '{owner}' records no oracle probe evidence"
573 )));
574 }
575 for probe_id in evidence {
576 let probe = probes.get(probe_id.as_str()).ok_or_else(|| {
577 ManifestError(format!(
578 "entry '{owner}' references undeclared probe '{probe_id}'"
579 ))
580 })?;
581 if probe.expect != "success" {
582 return Err(ManifestError(format!(
583 "entry '{owner}' references probe '{probe_id}' which does not record \
584 oracle acceptance"
585 )));
586 }
587 }
588 Ok(())
589 }
590
591 pub fn builtin() -> Result<&'static Manifest, ManifestError> {
593 static MANIFEST: OnceLock<Result<Manifest, ManifestError>> = OnceLock::new();
594 MANIFEST
595 .get_or_init(|| Manifest::load(MANIFEST_DATA, PROBES_DATA))
596 .as_ref()
597 .map_err(Clone::clone)
598 }
599
600 pub fn schema_version(&self) -> u32 {
602 self.schema_version
603 }
604
605 pub fn reference(&self) -> &Reference {
607 &self.reference
608 }
609
610 pub fn functions(&self) -> &[Function] {
612 &self.functions
613 }
614
615 pub fn aliases(&self) -> &[Alias] {
617 &self.aliases
618 }
619
620 pub fn provenance(&self) -> &Provenance {
622 &self.provenance
623 }
624
625 pub fn probes(&self) -> &[Probe] {
627 &self.probes
628 }
629
630 fn resolve_alias(&self, name: &str, kind: AliasKind) -> Option<&str> {
631 let alias = &self.aliases[*self.alias_by_source.get(name)?];
632 (alias.kind == kind).then_some(alias.target.as_str())
633 }
634
635 pub fn resolve_function(&self, name: &str) -> Option<&Function> {
637 self.function(name).or_else(|| {
638 self.resolve_alias(name, AliasKind::FunctionAlias)
639 .and_then(|target| self.function(target))
640 })
641 }
642
643 pub fn resolve_member(&self, name: &str) -> Option<&Function> {
645 self.member(name).or_else(|| {
646 self.resolve_alias(name, AliasKind::MemberAlias)
647 .and_then(|target| self.member(target))
648 })
649 }
650
651 pub fn function(&self, id: &str) -> Option<&Function> {
653 self.by_function.get(id).map(|i| &self.functions[*i])
654 }
655
656 pub fn member(&self, id: &str) -> Option<&Function> {
658 self.by_member.get(id).map(|i| &self.functions[*i])
659 }
660
661 pub fn domain_identity(&self, name: &str) -> bool {
667 self.domain_identities.contains(name)
668 }
669}
670
671pub fn canonicalize(manifest_json: &str, probes_json: &str) -> Result<String, ManifestError> {
676 Manifest::load(manifest_json, probes_json)?;
677 let value: serde_json::Value = serde_json::from_str(manifest_json)
678 .map_err(|error| ManifestError(format!("manifest data: {error}")))?;
679 serde_json::to_string_pretty(&value)
680 .map(|mut out| {
681 out.push('\n');
682 out
683 })
684 .map_err(|error| ManifestError(format!("cannot serialize manifest: {error}")))
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn builtin_manifest_loads_and_validates() {
693 let manifest = Manifest::builtin().expect("embedded manifest must validate");
694 assert_eq!(manifest.schema_version(), 1);
695 assert_eq!(manifest.reference().name, "overpy");
696 assert_eq!(manifest.reference().version, "9.7.10");
697 assert_eq!(
698 manifest.reference().content_commit,
699 "889d9749d1def17f146548cbddb94ea1ab015847"
700 );
701 assert!(!manifest.functions().is_empty());
702 assert!(!manifest.aliases().is_empty());
703 for domain in ["Invis", "ChaseTimeReeval", "Team", "LosCheck", "Color"] {
708 assert!(manifest.domain_identity(domain), "{domain}");
709 }
710 assert_eq!(
711 manifest
712 .function("chase")
713 .expect("chase entry")
714 .catalog_link,
715 CatalogLink::SpecialLowering
716 );
717 assert_eq!(
718 manifest
719 .member("getHero")
720 .expect("getHero entry")
721 .catalog_link,
722 CatalogLink::Canonical
723 );
724 assert!(
725 !manifest.domain_identity("ChaseReeval"),
726 "contextual domains are not standalone identities"
727 );
728 for function in manifest.functions() {
729 assert!(!function.evidence.is_empty(), "{}", function.id);
730 if function.kind.is_member() {
731 assert!(function.receiver.is_some(), "{}", function.id);
732 }
733 }
734 }
735
736 #[test]
737 fn manifest_data_is_canonical() {
738 let canonical = canonicalize(MANIFEST_DATA, PROBES_DATA).expect("canonicalizes");
741 assert_eq!(canonical, MANIFEST_DATA, "manifest.json must be canonical");
742 assert_eq!(
744 canonicalize(&canonical, PROBES_DATA).expect("re-canonicalizes"),
745 canonical
746 );
747 }
748
749 #[test]
750 fn manifest_omits_contextual_lowering_policy() {
751 assert!(!MANIFEST_DATA.contains("\"contextualDomain\""));
752 assert!(!MANIFEST_DATA.contains("\"context\": \"forIterable\""));
753 }
754
755 #[test]
756 fn validation_rejects_duplicates_and_missing_evidence() {
757 fn mutate(mutate: impl FnOnce(&mut ManifestFile)) -> Result<Manifest, ManifestError> {
758 let mut file: ManifestFile = serde_json::from_str(MANIFEST_DATA).unwrap();
759 mutate(&mut file);
760 Manifest::load(&serde_json::to_string(&file).unwrap(), PROBES_DATA)
761 }
762 let error = mutate(|file| file.functions.push(file.functions[0].clone()))
764 .expect_err("duplicate function id must fail");
765 assert!(error.0.contains("duplicate function id"));
766 let error = mutate(|file| file.functions[0].catalog_link = CatalogLink::CatalogGap)
768 .expect_err("canonical catalog id must not carry a gap reason");
769 assert!(error.0.contains("catalogLink"));
770 let error = mutate(|file| file.functions[0].evidence.clear())
772 .expect_err("missing evidence must fail");
773 assert!(error.0.contains("no oracle probe evidence"));
774 let error = mutate(|file| {
777 file.functions[0].params.push(Param {
778 name: "bad".to_string(),
779 domain: None,
780 default: Some(ParamDefault::EnumMember("X".to_string())),
781 optional: false,
782 keyword_only: false,
783 positional_only: false,
784 alternate_names: Vec::new(),
785 variable: false,
786 })
787 })
788 .expect_err("enum default without a domain must fail");
789 assert!(error.0.contains("no declared domain"));
790 }
791
792 #[test]
793 fn arity_bounds_follow_defaults_and_unbounded() {
794 let manifest = Manifest::builtin().expect("builtin");
795 let chase = manifest.function("chaseOverTime").expect("entry");
796 assert_eq!(chase.arity_bounds(), (3, Some(4)));
797 let radius = manifest.function("getPlayersInRadius").expect("entry");
798 assert_eq!(radius.arity_bounds(), (2, Some(4)));
799 let status = manifest.member("setStatusEffect").expect("entry");
800 assert_eq!(status.arity_bounds(), (3, Some(3)));
801 let format = manifest.member("format").expect("entry");
802 assert_eq!(format.arity_bounds(), (0, None));
803 let range = manifest.function("range").expect("entry");
804 assert_eq!(range.arity_bounds(), (1, Some(3)));
805 assert_eq!(
806 crate::lower::policy::function_context(&range.id),
807 Some(crate::lower::policy::FunctionContext::ForIterable)
808 );
809 }
810
811 #[test]
812 fn aliases_resolve_to_declared_targets() {
813 let manifest = Manifest::builtin().expect("builtin");
814 let alias = manifest
815 .resolve_function("stopChasingVariable")
816 .expect("alias");
817 assert_eq!(alias.id, "stopChasingVariable");
818 assert!(alias.kind.is_action());
819 let member = manifest.resolve_member("getCurrentHero").expect("alias");
820 assert_eq!(member.id, "getHero");
821 assert!(member.kind.is_value());
822 assert!(manifest.resolve_function("frobnicate").is_none());
824 assert!(manifest.resolve_member("frobnicate").is_none());
825 }
826
827 #[test]
828 fn canonical_ids_are_not_source_spellings() {
829 let manifest = Manifest::builtin().expect("builtin");
834 for (canonical, upstream, catalog_id) in [
835 ("evaluateOnce", "evalOnce", "evaluateOnce"),
836 (
837 "lastCreatedEntity",
838 "getLastCreatedEntity",
839 "lastCreatedEntity",
840 ),
841 ("lastTextId", "getLastCreatedText", "lastTextId"),
842 ("allTankHeroes", "getTankHeroes", "allTankHeroes"),
843 ("allDamageHeroes", "getDamageHeroes", "allDamageHeroes"),
844 ("allSupportHeroes", "getSupportHeroes", "allSupportHeroes"),
845 (
846 "destroyAllHudText",
847 "destroyAllHudTexts",
848 "destroyAllHudText",
849 ),
850 ] {
851 assert!(
852 manifest.resolve_function(canonical).is_none(),
853 "{canonical} must not be a callable source spelling"
854 );
855 let entry = manifest
856 .resolve_function(upstream)
857 .unwrap_or_else(|| panic!("{upstream} must resolve"));
858 assert_eq!(
859 entry.catalog_id.as_deref(),
860 Some(catalog_id),
861 "{upstream} must keep the canonical identity"
862 );
863 }
864 assert!(manifest.resolve_member("isButtonHeld").is_none());
865 let held = manifest
866 .resolve_member("isHoldingButton")
867 .expect("isHoldingButton");
868 assert_eq!(held.catalog_id.as_deref(), Some("isButtonHeld"));
869 for axis in ["x", "y", "z"] {
871 assert!(manifest.resolve_member(axis).is_none(), "{axis}()");
872 }
873 }
874}