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)]
145#[serde(untagged)]
146pub enum ParamDefault {
147 Call { call: String },
148 EnumMember(String),
149 Bool(bool),
150 Number(f64),
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct Param {
157 pub name: String,
158 #[serde(default)]
160 pub domain: Option<String>,
161 #[serde(default)]
163 pub default: Option<ParamDefault>,
164 #[serde(default)]
167 pub optional: bool,
168 #[serde(default)]
172 pub keyword_only: bool,
173 #[serde(default)]
177 pub positional_only: bool,
178 #[serde(default)]
182 pub alternate_names: Vec<String>,
183 #[serde(default)]
187 pub variable: bool,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase")]
193pub enum FunctionContext {
194 ForIterable,
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207#[serde(rename_all = "camelCase")]
208pub struct ContextualDomain {
209 pub domain: String,
212 pub by: String,
214 pub options: std::collections::BTreeMap<String, ContextualDomainOption>,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub struct ContextualDomainOption {
224 pub domain: String,
225 pub target: String,
226}
227
228#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct Function {
232 pub id: String,
233 pub kind: FunctionKind,
234 #[serde(default)]
236 pub receiver: Option<ReceiverCategory>,
237 #[serde(default)]
238 pub params: Vec<Param>,
239 #[serde(default)]
241 pub unbounded: bool,
242 #[serde(default = "default_keyword_args")]
248 pub keyword_args: bool,
249 #[serde(default)]
252 pub contextual_domain: Option<ContextualDomain>,
253 #[serde(default)]
254 pub context: Option<FunctionContext>,
255 #[serde(default)]
258 #[serde(rename = "catalogId")]
259 pub catalog_id: Option<String>,
260 #[serde(default)]
262 pub catalog_link: CatalogLink,
263 #[serde(default)]
265 pub evidence: Vec<String>,
266}
267
268impl Function {
269 pub fn arity_bounds(&self) -> (usize, Option<usize>) {
273 if self.unbounded {
274 return (0, None);
275 }
276 let first_default = self
277 .params
278 .iter()
279 .position(|param| param.default.is_some() || param.optional);
280 let min = first_default.unwrap_or(self.params.len());
281 (min, Some(self.params.len()))
282 }
283}
284
285fn default_keyword_args() -> bool {
286 true
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(rename_all = "camelCase")]
292pub struct Alias {
293 pub source: String,
294 pub target: String,
295 pub kind: AliasKind,
296 #[serde(default)]
297 pub evidence: Vec<String>,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "camelCase")]
304pub enum AliasKind {
305 FunctionAlias,
306 MemberAlias,
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "camelCase")]
312pub struct Probe {
313 pub id: String,
314 pub source: String,
315 pub sha256: String,
316 pub expect: String,
317 #[serde(default)]
318 pub output_sha256: Option<String>,
319 #[serde(default)]
320 pub diagnostic_contains: Option<String>,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq)]
325pub struct ManifestError(pub String);
326
327impl std::fmt::Display for ManifestError {
328 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329 f.write_str(&self.0)
330 }
331}
332
333impl std::error::Error for ManifestError {}
334
335#[derive(Debug, Clone)]
337pub struct Manifest {
338 pub schema_version: u32,
339 pub reference: Reference,
340 pub functions: Vec<Function>,
341 pub aliases: Vec<Alias>,
342 pub provenance: Provenance,
343 pub probes: Vec<Probe>,
345 by_function: HashMap<String, usize>,
346 by_member: HashMap<String, usize>,
347 alias_by_source: HashMap<String, usize>,
348 domain_identities: HashSet<String>,
353}
354
355#[derive(Serialize, Deserialize)]
356#[serde(rename_all = "camelCase")]
357struct ManifestFile {
358 schema_version: u32,
359 reference: Reference,
360 #[serde(default)]
361 functions: Vec<Function>,
362 #[serde(default)]
363 aliases: Vec<Alias>,
364 provenance: Provenance,
365}
366
367#[derive(Serialize, Deserialize)]
368#[serde(rename_all = "camelCase")]
369struct ProbesFile {
370 schema_version: u32,
371 #[serde(default)]
372 probes: Vec<Probe>,
373}
374
375impl Manifest {
376 pub fn load(manifest_json: &str, probes_json: &str) -> Result<Manifest, ManifestError> {
378 let file: ManifestFile = serde_json::from_str(manifest_json)
379 .map_err(|error| ManifestError(format!("manifest data: {error}")))?;
380 if file.schema_version != 1 {
381 return Err(ManifestError(format!(
382 "unsupported manifest schemaVersion {}",
383 file.schema_version
384 )));
385 }
386 let probes_file: ProbesFile = serde_json::from_str(probes_json)
387 .map_err(|error| ManifestError(format!("probes data: {error}")))?;
388 if probes_file.schema_version != 1 {
389 return Err(ManifestError(format!(
390 "unsupported probes schemaVersion {}",
391 probes_file.schema_version
392 )));
393 }
394 let mut manifest = Manifest {
395 schema_version: file.schema_version,
396 reference: file.reference.clone(),
397 functions: Vec::new(),
398 aliases: Vec::new(),
399 provenance: file.provenance.clone(),
400 probes: probes_file.probes,
401 by_function: HashMap::new(),
402 by_member: HashMap::new(),
403 alias_by_source: HashMap::new(),
404 domain_identities: HashSet::new(),
405 };
406 manifest.validate(file)?;
407 Ok(manifest)
408 }
409
410 fn validate(&mut self, file: ManifestFile) -> Result<(), ManifestError> {
411 let mut probes: HashMap<&str, &Probe> = HashMap::new();
414 for probe in &self.probes {
415 if probes.insert(&probe.id, probe).is_some() {
416 return Err(ManifestError(format!("duplicate probe id '{}'", probe.id)));
417 }
418 }
419
420 for function in &file.functions {
424 if self.by_function.contains_key(&function.id) {
425 return Err(ManifestError(format!(
426 "duplicate function id '{}'",
427 function.id
428 )));
429 }
430 match function.kind {
431 FunctionKind::MemberAction | FunctionKind::MemberValue => {
432 if function.receiver.is_none() {
433 return Err(ManifestError(format!(
434 "member function '{}' declares no receiver category",
435 function.id
436 )));
437 }
438 }
439 FunctionKind::Action | FunctionKind::Value => {
440 if function.receiver.is_some() {
441 return Err(ManifestError(format!(
442 "non-member function '{}' declares a receiver category",
443 function.id
444 )));
445 }
446 }
447 }
448 for param in function.params.iter() {
449 if let Some(domain) = ¶m.domain {
450 let is_contextual = function
455 .contextual_domain
456 .as_ref()
457 .is_some_and(|contextual| &contextual.domain == domain);
458 if !is_contextual {
459 self.domain_identities.insert(domain.clone());
460 }
461 } else if matches!(param.default, Some(ParamDefault::EnumMember(_))) {
462 return Err(ManifestError(format!(
463 "function '{}' parameter '{}' has an enum-member default but no \
464 declared domain",
465 function.id, param.name
466 )));
467 }
468 if param.keyword_only && param.positional_only {
469 return Err(ManifestError(format!(
470 "function '{}' parameter '{}' cannot be both keyword-only and \
471 positional-only",
472 function.id, param.name
473 )));
474 }
475 for alternate in ¶m.alternate_names {
476 if alternate == ¶m.name {
477 return Err(ManifestError(format!(
478 "function '{}' parameter '{}' repeats its name as an \
479 alternate keyword spelling",
480 function.id, param.name
481 )));
482 }
483 if function.params.iter().any(|other| {
484 !std::ptr::eq(other, param)
485 && (&other.name == alternate
486 || other.alternate_names.contains(alternate))
487 }) {
488 return Err(ManifestError(format!(
489 "function '{}' alternate keyword spelling '{alternate}' \
490 collides with another parameter",
491 function.id
492 )));
493 }
494 }
495 }
496 match (&function.catalog_id, function.catalog_link) {
497 (Some(_), CatalogLink::Canonical)
498 | (None, CatalogLink::SpecialLowering)
499 | (None, CatalogLink::LegacyAlias)
500 | (None, CatalogLink::CatalogGap) => {}
501 (Some(id), link) => {
502 return Err(ManifestError(format!(
503 "function '{}' has catalogId '{id}' but catalogLink is {:?}",
504 function.id, link
505 )));
506 }
507 (None, CatalogLink::Canonical) => {
508 return Err(ManifestError(format!(
509 "function '{}' has no catalogId or explicit catalogLink reason",
510 function.id
511 )));
512 }
513 }
514 if let Some(contextual) = &function.contextual_domain {
515 let by_param = function
516 .params
517 .iter()
518 .find(|param| param.name == contextual.by)
519 .ok_or_else(|| {
520 ManifestError(format!(
521 "function '{}' contextual domain '{}' references unknown \
522 selector parameter '{}'",
523 function.id, contextual.domain, contextual.by
524 ))
525 })?;
526 let contextual_param = function
527 .params
528 .iter()
529 .find(|param| param.domain.as_deref() == Some(contextual.domain.as_str()))
530 .ok_or_else(|| {
531 ManifestError(format!(
532 "function '{}' contextual domain '{}' has no parameter \
533 declaring that domain",
534 function.id, contextual.domain
535 ))
536 })?;
537 let _ = contextual_param;
538 let mut spellings = vec![by_param.name.clone()];
539 spellings.extend(by_param.alternate_names.iter().cloned());
540 for (keyword, option) in &contextual.options {
541 if !spellings.contains(keyword) {
542 return Err(ManifestError(format!(
543 "function '{}' contextual option '{keyword}' is not a \
544 keyword spelling of selector parameter '{}'",
545 function.id, by_param.name
546 )));
547 }
548 self.domain_identities.insert(option.domain.clone());
552 }
553 }
554 self.check_evidence(&function.id, &function.evidence, &probes)?;
555 if function.kind.is_member() {
556 self.by_member
557 .insert(function.id.clone(), self.functions.len());
558 } else {
559 self.by_function
560 .insert(function.id.clone(), self.functions.len());
561 }
562 self.functions.push(function.clone());
563 }
564
565 for alias in &file.aliases {
568 if self.alias_by_source.contains_key(&alias.source) {
569 return Err(ManifestError(format!(
570 "duplicate alias source '{}'",
571 alias.source
572 )));
573 }
574 if self.by_function.contains_key(&alias.source)
575 || self.by_member.contains_key(&alias.source)
576 {
577 return Err(ManifestError(format!(
578 "alias source '{}' collides with a declared function",
579 alias.source
580 )));
581 }
582 match alias.kind {
583 AliasKind::FunctionAlias => {
584 if self.function(&alias.target).is_none() {
585 return Err(ManifestError(format!(
586 "alias '{}' targets '{}' which is not a generic function",
587 alias.source, alias.target
588 )));
589 }
590 }
591 AliasKind::MemberAlias => {
592 if self.member(&alias.target).is_none() {
593 return Err(ManifestError(format!(
594 "alias '{}' targets '{}' which is not a member function",
595 alias.source, alias.target
596 )));
597 }
598 }
599 }
600 self.check_evidence(&alias.source, &alias.evidence, &probes)?;
601 self.alias_by_source
602 .insert(alias.source.clone(), self.aliases.len());
603 self.aliases.push(alias.clone());
604 }
605
606 Ok(())
607 }
608
609 fn check_evidence(
610 &self,
611 owner: &str,
612 evidence: &[String],
613 probes: &HashMap<&str, &Probe>,
614 ) -> Result<(), ManifestError> {
615 if evidence.is_empty() {
616 return Err(ManifestError(format!(
617 "entry '{owner}' records no oracle probe evidence"
618 )));
619 }
620 for probe_id in evidence {
621 let probe = probes.get(probe_id.as_str()).ok_or_else(|| {
622 ManifestError(format!(
623 "entry '{owner}' references undeclared probe '{probe_id}'"
624 ))
625 })?;
626 if probe.expect != "success" {
627 return Err(ManifestError(format!(
628 "entry '{owner}' references probe '{probe_id}' which does not record \
629 oracle acceptance"
630 )));
631 }
632 }
633 Ok(())
634 }
635
636 pub fn builtin() -> Result<&'static Manifest, ManifestError> {
638 static MANIFEST: OnceLock<Result<Manifest, ManifestError>> = OnceLock::new();
639 MANIFEST
640 .get_or_init(|| Manifest::load(MANIFEST_DATA, PROBES_DATA))
641 .as_ref()
642 .map_err(Clone::clone)
643 }
644
645 pub fn resolve_function(&self, name: &str) -> Option<&Function> {
647 self.function(name).or_else(|| {
648 let alias = self.alias_by_source.get(name)?;
649 let alias = &self.aliases[*alias];
650 (alias.kind == AliasKind::FunctionAlias)
651 .then(|| self.function(&alias.target))
652 .flatten()
653 })
654 }
655
656 pub fn resolve_member(&self, name: &str) -> Option<&Function> {
658 self.member(name).or_else(|| {
659 let alias = self.alias_by_source.get(name)?;
660 let alias = &self.aliases[*alias];
661 (alias.kind == AliasKind::MemberAlias)
662 .then(|| self.member(&alias.target))
663 .flatten()
664 })
665 }
666
667 pub fn function(&self, id: &str) -> Option<&Function> {
669 self.by_function.get(id).map(|i| &self.functions[*i])
670 }
671
672 pub fn member(&self, id: &str) -> Option<&Function> {
674 self.by_member.get(id).map(|i| &self.functions[*i])
675 }
676
677 pub fn domain_identity(&self, name: &str) -> bool {
683 self.domain_identities.contains(name)
684 }
685}
686
687pub fn canonicalize(manifest_json: &str, probes_json: &str) -> Result<String, ManifestError> {
692 Manifest::load(manifest_json, probes_json)?;
693 let value: serde_json::Value = serde_json::from_str(manifest_json)
694 .map_err(|error| ManifestError(format!("manifest data: {error}")))?;
695 serde_json::to_string_pretty(&value)
696 .map(|mut out| {
697 out.push('\n');
698 out
699 })
700 .map_err(|error| ManifestError(format!("cannot serialize manifest: {error}")))
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706
707 #[test]
708 fn builtin_manifest_loads_and_validates() {
709 let manifest = Manifest::builtin().expect("embedded manifest must validate");
710 assert_eq!(manifest.schema_version, 1);
711 assert_eq!(manifest.reference.name, "overpy");
712 assert_eq!(manifest.reference.version, "9.7.10");
713 assert_eq!(
714 manifest.reference.content_commit,
715 "889d9749d1def17f146548cbddb94ea1ab015847"
716 );
717 assert!(!manifest.functions.is_empty());
718 assert!(!manifest.aliases.is_empty());
719 for domain in ["Invis", "ChaseTimeReeval", "Team", "LosCheck", "Color"] {
724 assert!(manifest.domain_identity(domain), "{domain}");
725 }
726 assert_eq!(
727 manifest
728 .function("chase")
729 .expect("chase entry")
730 .catalog_link,
731 CatalogLink::SpecialLowering
732 );
733 assert_eq!(
734 manifest
735 .member("getHero")
736 .expect("getHero entry")
737 .catalog_link,
738 CatalogLink::Canonical
739 );
740 assert!(
741 !manifest.domain_identity("ChaseReeval"),
742 "contextual domains are not standalone identities"
743 );
744 for function in &manifest.functions {
745 assert!(!function.evidence.is_empty(), "{}", function.id);
746 if function.kind.is_member() {
747 assert!(function.receiver.is_some(), "{}", function.id);
748 }
749 }
750 }
751
752 #[test]
753 fn manifest_data_is_canonical() {
754 let canonical = canonicalize(MANIFEST_DATA, PROBES_DATA).expect("canonicalizes");
757 assert_eq!(canonical, MANIFEST_DATA, "manifest.json must be canonical");
758 assert_eq!(
760 canonicalize(&canonical, PROBES_DATA).expect("re-canonicalizes"),
761 canonical
762 );
763 }
764
765 #[test]
766 fn validation_rejects_duplicates_and_missing_evidence() {
767 fn mutate(mutate: impl FnOnce(&mut ManifestFile)) -> Result<Manifest, ManifestError> {
768 let mut file: ManifestFile = serde_json::from_str(MANIFEST_DATA).unwrap();
769 mutate(&mut file);
770 Manifest::load(&serde_json::to_string(&file).unwrap(), PROBES_DATA)
771 }
772 let error = mutate(|file| file.functions.push(file.functions[0].clone()))
774 .expect_err("duplicate function id must fail");
775 assert!(error.0.contains("duplicate function id"));
776 let error = mutate(|file| file.functions[0].catalog_link = CatalogLink::CatalogGap)
778 .expect_err("canonical catalog id must not carry a gap reason");
779 assert!(error.0.contains("catalogLink"));
780 let error = mutate(|file| file.functions[0].evidence.clear())
782 .expect_err("missing evidence must fail");
783 assert!(error.0.contains("no oracle probe evidence"));
784 let error = mutate(|file| {
787 file.functions[0].params.push(Param {
788 name: "bad".to_string(),
789 domain: None,
790 default: Some(ParamDefault::EnumMember("X".to_string())),
791 optional: false,
792 keyword_only: false,
793 positional_only: false,
794 alternate_names: Vec::new(),
795 variable: false,
796 })
797 })
798 .expect_err("enum default without a domain must fail");
799 assert!(error.0.contains("no declared domain"));
800 }
801
802 #[test]
803 fn arity_bounds_follow_defaults_and_unbounded() {
804 let manifest = Manifest::builtin().expect("builtin");
805 let chase = manifest.function("chaseOverTime").expect("entry");
806 assert_eq!(chase.arity_bounds(), (3, Some(4)));
807 let radius = manifest.function("getPlayersInRadius").expect("entry");
808 assert_eq!(radius.arity_bounds(), (2, Some(4)));
809 let status = manifest.member("setStatusEffect").expect("entry");
810 assert_eq!(status.arity_bounds(), (3, Some(3)));
811 let format = manifest.member("format").expect("entry");
812 assert_eq!(format.arity_bounds(), (0, None));
813 let range = manifest.function("range").expect("entry");
814 assert_eq!(range.arity_bounds(), (1, Some(3)));
815 assert_eq!(range.context, Some(FunctionContext::ForIterable));
816 }
817
818 #[test]
819 fn aliases_resolve_to_declared_targets() {
820 let manifest = Manifest::builtin().expect("builtin");
821 let alias = manifest
822 .resolve_function("stopChasingVariable")
823 .expect("alias");
824 assert_eq!(alias.id, "stopChasingVariable");
825 assert!(alias.kind.is_action());
826 let member = manifest.resolve_member("getCurrentHero").expect("alias");
827 assert_eq!(member.id, "getHero");
828 assert!(member.kind.is_value());
829 assert!(manifest.resolve_function("frobnicate").is_none());
831 assert!(manifest.resolve_member("frobnicate").is_none());
832 }
833}