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