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)]
120#[serde(rename_all = "PascalCase")]
121pub enum ReceiverCategory {
122 Player,
123 Variable,
124 String,
125 Vector,
126 Any,
127}
128
129impl ReceiverCategory {
130 pub fn describe(self) -> &'static str {
132 match self {
133 ReceiverCategory::Player => "a player-valued expression",
134 ReceiverCategory::Variable => "an assignable variable",
135 ReceiverCategory::String => "a string literal",
136 ReceiverCategory::Vector => "a vector-valued expression",
137 ReceiverCategory::Any => "any expression",
138 }
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[serde(untagged)]
147pub enum ParamDefault {
148 Call {
149 call: String,
150 #[serde(default)]
151 args: Vec<f64>,
152 },
153 Null {
154 null: bool,
155 },
156 EnumMember(String),
157 Bool(bool),
158 Number(f64),
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct Param {
165 pub name: String,
166 #[serde(default)]
168 pub domain: Option<String>,
169 #[serde(default)]
171 pub default: Option<ParamDefault>,
172 #[serde(default)]
175 pub optional: bool,
176 #[serde(default)]
180 pub keyword_only: bool,
181 #[serde(default)]
185 pub positional_only: bool,
186 #[serde(default)]
190 pub alternate_names: Vec<String>,
191 #[serde(default)]
195 pub variable: bool,
196}
197
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct Function {
202 pub id: String,
203 pub kind: FunctionKind,
204 #[serde(default)]
206 pub receiver: Option<ReceiverCategory>,
207 #[serde(default)]
208 pub params: Vec<Param>,
209 #[serde(default)]
211 pub unbounded: bool,
212 #[serde(default = "default_keyword_args")]
218 pub keyword_args: bool,
219 #[serde(default)]
222 #[serde(rename = "catalogId")]
223 pub catalog_id: Option<String>,
224 #[serde(default)]
226 pub catalog_link: CatalogLink,
227 #[serde(default)]
229 pub evidence: Vec<String>,
230}
231
232impl Function {
233 pub fn arity_bounds(&self) -> (usize, Option<usize>) {
237 if self.unbounded {
238 return (0, None);
239 }
240 let min = self
241 .params
242 .iter()
243 .rposition(|param| param.default.is_none() && !param.optional)
244 .map_or(0, |index| index + 1);
245 (min, Some(self.params.len()))
246 }
247}
248
249fn default_keyword_args() -> bool {
250 true
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct Alias {
257 pub source: String,
258 pub target: String,
259 pub kind: AliasKind,
260 #[serde(default)]
261 pub evidence: Vec<String>,
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub enum AliasKind {
269 FunctionAlias,
270 MemberAlias,
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "camelCase")]
276pub struct Probe {
277 pub id: String,
278 pub source: String,
279 pub sha256: String,
280 pub expect: String,
281 #[serde(default)]
282 pub output_sha256: Option<String>,
283 #[serde(default)]
284 pub diagnostic_contains: Option<String>,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct ManifestError(pub String);
290
291impl std::fmt::Display for ManifestError {
292 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293 f.write_str(&self.0)
294 }
295}
296
297impl std::error::Error for ManifestError {}
298
299#[derive(Debug, Clone)]
301pub struct Manifest {
302 pub schema_version: u32,
303 pub reference: Reference,
304 pub functions: Vec<Function>,
305 pub aliases: Vec<Alias>,
306 pub provenance: Provenance,
307 pub probes: Vec<Probe>,
309 by_function: HashMap<String, usize>,
310 by_member: HashMap<String, usize>,
311 alias_by_source: HashMap<String, usize>,
312 domain_identities: HashSet<String>,
317}
318
319#[derive(Serialize, Deserialize)]
320#[serde(rename_all = "camelCase")]
321struct ManifestFile {
322 schema_version: u32,
323 reference: Reference,
324 #[serde(default)]
325 functions: Vec<Function>,
326 #[serde(default)]
327 aliases: Vec<Alias>,
328 provenance: Provenance,
329}
330
331#[derive(Serialize, Deserialize)]
332#[serde(rename_all = "camelCase")]
333struct ProbesFile {
334 schema_version: u32,
335 #[serde(default)]
336 probes: Vec<Probe>,
337}
338
339impl Manifest {
340 pub fn load(manifest_json: &str, probes_json: &str) -> Result<Manifest, ManifestError> {
342 let file: ManifestFile = serde_json::from_str(manifest_json)
343 .map_err(|error| ManifestError(format!("manifest data: {error}")))?;
344 if file.schema_version != 1 {
345 return Err(ManifestError(format!(
346 "unsupported manifest schemaVersion {}",
347 file.schema_version
348 )));
349 }
350 let probes_file: ProbesFile = serde_json::from_str(probes_json)
351 .map_err(|error| ManifestError(format!("probes data: {error}")))?;
352 if probes_file.schema_version != 1 {
353 return Err(ManifestError(format!(
354 "unsupported probes schemaVersion {}",
355 probes_file.schema_version
356 )));
357 }
358 let ManifestFile {
359 schema_version,
360 reference,
361 functions,
362 aliases,
363 provenance,
364 } = file;
365 let mut manifest = Manifest {
366 schema_version,
367 reference,
368 functions: Vec::with_capacity(functions.len()),
369 aliases: Vec::with_capacity(aliases.len()),
370 provenance,
371 probes: probes_file.probes,
372 by_function: HashMap::new(),
373 by_member: HashMap::new(),
374 alias_by_source: HashMap::new(),
375 domain_identities: HashSet::new(),
376 };
377 manifest.validate(functions, aliases)?;
378 Ok(manifest)
379 }
380
381 fn validate(
382 &mut self,
383 functions: Vec<Function>,
384 aliases: Vec<Alias>,
385 ) -> Result<(), ManifestError> {
386 let mut probes: HashMap<&str, &Probe> = HashMap::new();
389 for probe in &self.probes {
390 if probes.insert(&probe.id, probe).is_some() {
391 return Err(ManifestError(format!("duplicate probe id '{}'", probe.id)));
392 }
393 }
394
395 for function in functions {
399 if self.by_function.contains_key(&function.id) {
400 return Err(ManifestError(format!(
401 "duplicate function id '{}'",
402 function.id
403 )));
404 }
405 match function.kind {
406 FunctionKind::MemberAction | FunctionKind::MemberValue => {
407 if function.receiver.is_none() {
408 return Err(ManifestError(format!(
409 "member function '{}' declares no receiver category",
410 function.id
411 )));
412 }
413 }
414 FunctionKind::Action | FunctionKind::Value => {
415 if function.receiver.is_some() {
416 return Err(ManifestError(format!(
417 "non-member function '{}' declares a receiver category",
418 function.id
419 )));
420 }
421 }
422 }
423 for param in function.params.iter() {
424 if let Some(domain) = ¶m.domain {
425 let is_contextual = crate::lower::policy::is_contextual_domain(domain);
430 if !is_contextual {
431 self.domain_identities.insert(domain.clone());
432 }
433 } else if matches!(param.default, Some(ParamDefault::EnumMember(_))) {
434 return Err(ManifestError(format!(
435 "function '{}' parameter '{}' has an enum-member default but no \
436 declared domain",
437 function.id, param.name
438 )));
439 }
440 if param.keyword_only && param.positional_only {
441 return Err(ManifestError(format!(
442 "function '{}' parameter '{}' cannot be both keyword-only and \
443 positional-only",
444 function.id, param.name
445 )));
446 }
447 for alternate in ¶m.alternate_names {
448 if alternate == ¶m.name {
449 return Err(ManifestError(format!(
450 "function '{}' parameter '{}' repeats its name as an \
451 alternate keyword spelling",
452 function.id, param.name
453 )));
454 }
455 if function.params.iter().any(|other| {
456 !std::ptr::eq(other, param)
457 && (&other.name == alternate
458 || other.alternate_names.contains(alternate))
459 }) {
460 return Err(ManifestError(format!(
461 "function '{}' alternate keyword spelling '{alternate}' \
462 collides with another parameter",
463 function.id
464 )));
465 }
466 }
467 }
468 match (&function.catalog_id, function.catalog_link) {
469 (Some(_), CatalogLink::Canonical)
470 | (None, CatalogLink::SpecialLowering)
471 | (None, CatalogLink::LegacyAlias)
472 | (None, CatalogLink::CatalogGap) => {}
473 (Some(id), link) => {
474 return Err(ManifestError(format!(
475 "function '{}' has catalogId '{id}' but catalogLink is {:?}",
476 function.id, link
477 )));
478 }
479 (None, CatalogLink::Canonical) => {
480 return Err(ManifestError(format!(
481 "function '{}' has no catalogId or explicit catalogLink reason",
482 function.id
483 )));
484 }
485 }
486 crate::lower::policy::validate(&function).map_err(ManifestError)?;
487 if let Some(contextual) = crate::lower::policy::contextual_domain(&function.id) {
488 for option in contextual.options {
489 self.domain_identities.insert(option.domain.to_string());
490 }
491 }
492 self.check_evidence(&function.id, &function.evidence, &probes)?;
493 if function.kind.is_member() {
494 self.by_member
495 .insert(function.id.clone(), self.functions.len());
496 } else {
497 self.by_function
498 .insert(function.id.clone(), self.functions.len());
499 }
500 self.functions.push(function);
501 }
502
503 for alias in aliases {
506 if self.alias_by_source.contains_key(&alias.source) {
507 return Err(ManifestError(format!(
508 "duplicate alias source '{}'",
509 alias.source
510 )));
511 }
512 if self.by_function.contains_key(&alias.source)
513 || self.by_member.contains_key(&alias.source)
514 {
515 return Err(ManifestError(format!(
516 "alias source '{}' collides with a declared function",
517 alias.source
518 )));
519 }
520 match alias.kind {
521 AliasKind::FunctionAlias => {
522 if self.function(&alias.target).is_none() {
523 return Err(ManifestError(format!(
524 "alias '{}' targets '{}' which is not a generic function",
525 alias.source, alias.target
526 )));
527 }
528 }
529 AliasKind::MemberAlias => {
530 if self.member(&alias.target).is_none() {
531 return Err(ManifestError(format!(
532 "alias '{}' targets '{}' which is not a member function",
533 alias.source, alias.target
534 )));
535 }
536 }
537 }
538 self.check_evidence(&alias.source, &alias.evidence, &probes)?;
539 self.alias_by_source
540 .insert(alias.source.clone(), self.aliases.len());
541 self.aliases.push(alias);
542 }
543
544 Ok(())
545 }
546
547 fn check_evidence(
548 &self,
549 owner: &str,
550 evidence: &[String],
551 probes: &HashMap<&str, &Probe>,
552 ) -> Result<(), ManifestError> {
553 if evidence.is_empty() {
554 return Err(ManifestError(format!(
555 "entry '{owner}' records no oracle probe evidence"
556 )));
557 }
558 for probe_id in evidence {
559 let probe = probes.get(probe_id.as_str()).ok_or_else(|| {
560 ManifestError(format!(
561 "entry '{owner}' references undeclared probe '{probe_id}'"
562 ))
563 })?;
564 if probe.expect != "success" {
565 return Err(ManifestError(format!(
566 "entry '{owner}' references probe '{probe_id}' which does not record \
567 oracle acceptance"
568 )));
569 }
570 }
571 Ok(())
572 }
573
574 pub fn builtin() -> Result<&'static Manifest, ManifestError> {
576 static MANIFEST: OnceLock<Result<Manifest, ManifestError>> = OnceLock::new();
577 MANIFEST
578 .get_or_init(|| Manifest::load(MANIFEST_DATA, PROBES_DATA))
579 .as_ref()
580 .map_err(Clone::clone)
581 }
582
583 fn resolve_alias(&self, name: &str, kind: AliasKind) -> Option<&str> {
584 let alias = &self.aliases[*self.alias_by_source.get(name)?];
585 (alias.kind == kind).then_some(alias.target.as_str())
586 }
587
588 pub fn resolve_function(&self, name: &str) -> Option<&Function> {
590 self.function(name).or_else(|| {
591 self.resolve_alias(name, AliasKind::FunctionAlias)
592 .and_then(|target| self.function(target))
593 })
594 }
595
596 pub fn resolve_member(&self, name: &str) -> Option<&Function> {
598 self.member(name).or_else(|| {
599 self.resolve_alias(name, AliasKind::MemberAlias)
600 .and_then(|target| self.member(target))
601 })
602 }
603
604 pub fn function(&self, id: &str) -> Option<&Function> {
606 self.by_function.get(id).map(|i| &self.functions[*i])
607 }
608
609 pub fn member(&self, id: &str) -> Option<&Function> {
611 self.by_member.get(id).map(|i| &self.functions[*i])
612 }
613
614 pub fn domain_identity(&self, name: &str) -> bool {
620 self.domain_identities.contains(name)
621 }
622}
623
624pub fn canonicalize(manifest_json: &str, probes_json: &str) -> Result<String, ManifestError> {
629 Manifest::load(manifest_json, probes_json)?;
630 let value: serde_json::Value = serde_json::from_str(manifest_json)
631 .map_err(|error| ManifestError(format!("manifest data: {error}")))?;
632 serde_json::to_string_pretty(&value)
633 .map(|mut out| {
634 out.push('\n');
635 out
636 })
637 .map_err(|error| ManifestError(format!("cannot serialize manifest: {error}")))
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 #[test]
645 fn builtin_manifest_loads_and_validates() {
646 let manifest = Manifest::builtin().expect("embedded manifest must validate");
647 assert_eq!(manifest.schema_version, 1);
648 assert_eq!(manifest.reference.name, "overpy");
649 assert_eq!(manifest.reference.version, "9.7.10");
650 assert_eq!(
651 manifest.reference.content_commit,
652 "889d9749d1def17f146548cbddb94ea1ab015847"
653 );
654 assert!(!manifest.functions.is_empty());
655 assert!(!manifest.aliases.is_empty());
656 for domain in ["Invis", "ChaseTimeReeval", "Team", "LosCheck", "Color"] {
661 assert!(manifest.domain_identity(domain), "{domain}");
662 }
663 assert_eq!(
664 manifest
665 .function("chase")
666 .expect("chase entry")
667 .catalog_link,
668 CatalogLink::SpecialLowering
669 );
670 assert_eq!(
671 manifest
672 .member("getHero")
673 .expect("getHero entry")
674 .catalog_link,
675 CatalogLink::Canonical
676 );
677 assert!(
678 !manifest.domain_identity("ChaseReeval"),
679 "contextual domains are not standalone identities"
680 );
681 for function in &manifest.functions {
682 assert!(!function.evidence.is_empty(), "{}", function.id);
683 if function.kind.is_member() {
684 assert!(function.receiver.is_some(), "{}", function.id);
685 }
686 }
687 }
688
689 #[test]
690 fn manifest_data_is_canonical() {
691 let canonical = canonicalize(MANIFEST_DATA, PROBES_DATA).expect("canonicalizes");
694 assert_eq!(canonical, MANIFEST_DATA, "manifest.json must be canonical");
695 assert_eq!(
697 canonicalize(&canonical, PROBES_DATA).expect("re-canonicalizes"),
698 canonical
699 );
700 }
701
702 #[test]
703 fn manifest_omits_contextual_lowering_policy() {
704 assert!(!MANIFEST_DATA.contains("\"contextualDomain\""));
705 assert!(!MANIFEST_DATA.contains("\"context\": \"forIterable\""));
706 }
707
708 #[test]
709 fn validation_rejects_duplicates_and_missing_evidence() {
710 fn mutate(mutate: impl FnOnce(&mut ManifestFile)) -> Result<Manifest, ManifestError> {
711 let mut file: ManifestFile = serde_json::from_str(MANIFEST_DATA).unwrap();
712 mutate(&mut file);
713 Manifest::load(&serde_json::to_string(&file).unwrap(), PROBES_DATA)
714 }
715 let error = mutate(|file| file.functions.push(file.functions[0].clone()))
717 .expect_err("duplicate function id must fail");
718 assert!(error.0.contains("duplicate function id"));
719 let error = mutate(|file| file.functions[0].catalog_link = CatalogLink::CatalogGap)
721 .expect_err("canonical catalog id must not carry a gap reason");
722 assert!(error.0.contains("catalogLink"));
723 let error = mutate(|file| file.functions[0].evidence.clear())
725 .expect_err("missing evidence must fail");
726 assert!(error.0.contains("no oracle probe evidence"));
727 let error = mutate(|file| {
730 file.functions[0].params.push(Param {
731 name: "bad".to_string(),
732 domain: None,
733 default: Some(ParamDefault::EnumMember("X".to_string())),
734 optional: false,
735 keyword_only: false,
736 positional_only: false,
737 alternate_names: Vec::new(),
738 variable: false,
739 })
740 })
741 .expect_err("enum default without a domain must fail");
742 assert!(error.0.contains("no declared domain"));
743 }
744
745 #[test]
746 fn arity_bounds_follow_defaults_and_unbounded() {
747 let manifest = Manifest::builtin().expect("builtin");
748 let chase = manifest.function("chaseOverTime").expect("entry");
749 assert_eq!(chase.arity_bounds(), (3, Some(4)));
750 let radius = manifest.function("getPlayersInRadius").expect("entry");
751 assert_eq!(radius.arity_bounds(), (2, Some(4)));
752 let status = manifest.member("setStatusEffect").expect("entry");
753 assert_eq!(status.arity_bounds(), (3, Some(3)));
754 let format = manifest.member("format").expect("entry");
755 assert_eq!(format.arity_bounds(), (0, None));
756 let range = manifest.function("range").expect("entry");
757 assert_eq!(range.arity_bounds(), (1, Some(3)));
758 assert_eq!(
759 crate::lower::policy::function_context(&range.id),
760 Some(crate::lower::policy::FunctionContext::ForIterable)
761 );
762 }
763
764 #[test]
765 fn aliases_resolve_to_declared_targets() {
766 let manifest = Manifest::builtin().expect("builtin");
767 let alias = manifest
768 .resolve_function("stopChasingVariable")
769 .expect("alias");
770 assert_eq!(alias.id, "stopChasingVariable");
771 assert!(alias.kind.is_action());
772 let member = manifest.resolve_member("getCurrentHero").expect("alias");
773 assert_eq!(member.id, "getHero");
774 assert!(member.kind.is_value());
775 assert!(manifest.resolve_function("frobnicate").is_none());
777 assert!(manifest.resolve_member("frobnicate").is_none());
778 }
779
780 #[test]
781 fn canonical_ids_are_not_source_spellings() {
782 let manifest = Manifest::builtin().expect("builtin");
787 for (canonical, upstream, catalog_id) in [
788 ("evaluateOnce", "evalOnce", "evaluateOnce"),
789 (
790 "lastCreatedEntity",
791 "getLastCreatedEntity",
792 "lastCreatedEntity",
793 ),
794 ("lastTextId", "getLastCreatedText", "lastTextId"),
795 ("allTankHeroes", "getTankHeroes", "allTankHeroes"),
796 ("allDamageHeroes", "getDamageHeroes", "allDamageHeroes"),
797 ("allSupportHeroes", "getSupportHeroes", "allSupportHeroes"),
798 (
799 "destroyAllHudText",
800 "destroyAllHudTexts",
801 "destroyAllHudText",
802 ),
803 ] {
804 assert!(
805 manifest.resolve_function(canonical).is_none(),
806 "{canonical} must not be a callable source spelling"
807 );
808 let entry = manifest
809 .resolve_function(upstream)
810 .unwrap_or_else(|| panic!("{upstream} must resolve"));
811 assert_eq!(
812 entry.catalog_id.as_deref(),
813 Some(catalog_id),
814 "{upstream} must keep the canonical identity"
815 );
816 }
817 assert!(manifest.resolve_member("isButtonHeld").is_none());
818 let held = manifest
819 .resolve_member("isHoldingButton")
820 .expect("isHoldingButton");
821 assert_eq!(held.catalog_id.as_deref(), Some("isButtonHeld"));
822 for axis in ["x", "y", "z"] {
824 assert!(manifest.resolve_member(axis).is_none(), "{axis}()");
825 }
826 }
827}