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