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 Null { null: bool },
149 EnumMember(String),
150 Bool(bool),
151 Number(f64),
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct Param {
158 pub name: String,
159 #[serde(default)]
161 pub domain: Option<String>,
162 #[serde(default)]
164 pub default: Option<ParamDefault>,
165 #[serde(default)]
168 pub optional: bool,
169 #[serde(default)]
173 pub keyword_only: bool,
174 #[serde(default)]
178 pub positional_only: bool,
179 #[serde(default)]
183 pub alternate_names: Vec<String>,
184 #[serde(default)]
188 pub variable: bool,
189}
190
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub struct Function {
195 pub id: String,
196 pub kind: FunctionKind,
197 #[serde(default)]
199 pub receiver: Option<ReceiverCategory>,
200 #[serde(default)]
201 pub params: Vec<Param>,
202 #[serde(default)]
204 pub unbounded: bool,
205 #[serde(default = "default_keyword_args")]
211 pub keyword_args: bool,
212 #[serde(default)]
215 #[serde(rename = "catalogId")]
216 pub catalog_id: Option<String>,
217 #[serde(default)]
219 pub catalog_link: CatalogLink,
220 #[serde(default)]
222 pub evidence: Vec<String>,
223}
224
225impl Function {
226 pub fn arity_bounds(&self) -> (usize, Option<usize>) {
230 if self.unbounded {
231 return (0, None);
232 }
233 let first_default = self
234 .params
235 .iter()
236 .position(|param| param.default.is_some() || param.optional);
237 let min = first_default.unwrap_or(self.params.len());
238 (min, Some(self.params.len()))
239 }
240}
241
242fn default_keyword_args() -> bool {
243 true
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "camelCase")]
249pub struct Alias {
250 pub source: String,
251 pub target: String,
252 pub kind: AliasKind,
253 #[serde(default)]
254 pub evidence: Vec<String>,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "camelCase")]
261pub enum AliasKind {
262 FunctionAlias,
263 MemberAlias,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "camelCase")]
269pub struct Probe {
270 pub id: String,
271 pub source: String,
272 pub sha256: String,
273 pub expect: String,
274 #[serde(default)]
275 pub output_sha256: Option<String>,
276 #[serde(default)]
277 pub diagnostic_contains: Option<String>,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct ManifestError(pub String);
283
284impl std::fmt::Display for ManifestError {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 f.write_str(&self.0)
287 }
288}
289
290impl std::error::Error for ManifestError {}
291
292#[derive(Debug, Clone)]
294pub struct Manifest {
295 pub schema_version: u32,
296 pub reference: Reference,
297 pub functions: Vec<Function>,
298 pub aliases: Vec<Alias>,
299 pub provenance: Provenance,
300 pub probes: Vec<Probe>,
302 by_function: HashMap<String, usize>,
303 by_member: HashMap<String, usize>,
304 alias_by_source: HashMap<String, usize>,
305 domain_identities: HashSet<String>,
310}
311
312#[derive(Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314struct ManifestFile {
315 schema_version: u32,
316 reference: Reference,
317 #[serde(default)]
318 functions: Vec<Function>,
319 #[serde(default)]
320 aliases: Vec<Alias>,
321 provenance: Provenance,
322}
323
324#[derive(Serialize, Deserialize)]
325#[serde(rename_all = "camelCase")]
326struct ProbesFile {
327 schema_version: u32,
328 #[serde(default)]
329 probes: Vec<Probe>,
330}
331
332impl Manifest {
333 pub fn load(manifest_json: &str, probes_json: &str) -> Result<Manifest, ManifestError> {
335 let file: ManifestFile = serde_json::from_str(manifest_json)
336 .map_err(|error| ManifestError(format!("manifest data: {error}")))?;
337 if file.schema_version != 1 {
338 return Err(ManifestError(format!(
339 "unsupported manifest schemaVersion {}",
340 file.schema_version
341 )));
342 }
343 let probes_file: ProbesFile = serde_json::from_str(probes_json)
344 .map_err(|error| ManifestError(format!("probes data: {error}")))?;
345 if probes_file.schema_version != 1 {
346 return Err(ManifestError(format!(
347 "unsupported probes schemaVersion {}",
348 probes_file.schema_version
349 )));
350 }
351 let ManifestFile {
352 schema_version,
353 reference,
354 functions,
355 aliases,
356 provenance,
357 } = file;
358 let mut manifest = Manifest {
359 schema_version,
360 reference,
361 functions: Vec::with_capacity(functions.len()),
362 aliases: Vec::with_capacity(aliases.len()),
363 provenance,
364 probes: probes_file.probes,
365 by_function: HashMap::new(),
366 by_member: HashMap::new(),
367 alias_by_source: HashMap::new(),
368 domain_identities: HashSet::new(),
369 };
370 manifest.validate(functions, aliases)?;
371 Ok(manifest)
372 }
373
374 fn validate(
375 &mut self,
376 functions: Vec<Function>,
377 aliases: Vec<Alias>,
378 ) -> Result<(), ManifestError> {
379 let mut probes: HashMap<&str, &Probe> = HashMap::new();
382 for probe in &self.probes {
383 if probes.insert(&probe.id, probe).is_some() {
384 return Err(ManifestError(format!("duplicate probe id '{}'", probe.id)));
385 }
386 }
387
388 for function in functions {
392 if self.by_function.contains_key(&function.id) {
393 return Err(ManifestError(format!(
394 "duplicate function id '{}'",
395 function.id
396 )));
397 }
398 match function.kind {
399 FunctionKind::MemberAction | FunctionKind::MemberValue => {
400 if function.receiver.is_none() {
401 return Err(ManifestError(format!(
402 "member function '{}' declares no receiver category",
403 function.id
404 )));
405 }
406 }
407 FunctionKind::Action | FunctionKind::Value => {
408 if function.receiver.is_some() {
409 return Err(ManifestError(format!(
410 "non-member function '{}' declares a receiver category",
411 function.id
412 )));
413 }
414 }
415 }
416 for param in function.params.iter() {
417 if let Some(domain) = ¶m.domain {
418 let is_contextual = crate::lower::policy::is_contextual_domain(domain);
423 if !is_contextual {
424 self.domain_identities.insert(domain.clone());
425 }
426 } else if matches!(param.default, Some(ParamDefault::EnumMember(_))) {
427 return Err(ManifestError(format!(
428 "function '{}' parameter '{}' has an enum-member default but no \
429 declared domain",
430 function.id, param.name
431 )));
432 }
433 if param.keyword_only && param.positional_only {
434 return Err(ManifestError(format!(
435 "function '{}' parameter '{}' cannot be both keyword-only and \
436 positional-only",
437 function.id, param.name
438 )));
439 }
440 for alternate in ¶m.alternate_names {
441 if alternate == ¶m.name {
442 return Err(ManifestError(format!(
443 "function '{}' parameter '{}' repeats its name as an \
444 alternate keyword spelling",
445 function.id, param.name
446 )));
447 }
448 if function.params.iter().any(|other| {
449 !std::ptr::eq(other, param)
450 && (&other.name == alternate
451 || other.alternate_names.contains(alternate))
452 }) {
453 return Err(ManifestError(format!(
454 "function '{}' alternate keyword spelling '{alternate}' \
455 collides with another parameter",
456 function.id
457 )));
458 }
459 }
460 }
461 match (&function.catalog_id, function.catalog_link) {
462 (Some(_), CatalogLink::Canonical)
463 | (None, CatalogLink::SpecialLowering)
464 | (None, CatalogLink::LegacyAlias)
465 | (None, CatalogLink::CatalogGap) => {}
466 (Some(id), link) => {
467 return Err(ManifestError(format!(
468 "function '{}' has catalogId '{id}' but catalogLink is {:?}",
469 function.id, link
470 )));
471 }
472 (None, CatalogLink::Canonical) => {
473 return Err(ManifestError(format!(
474 "function '{}' has no catalogId or explicit catalogLink reason",
475 function.id
476 )));
477 }
478 }
479 crate::lower::policy::validate(&function).map_err(ManifestError)?;
480 if let Some(contextual) = crate::lower::policy::contextual_domain(&function.id) {
481 for option in contextual.options {
482 self.domain_identities.insert(option.domain.to_string());
483 }
484 }
485 self.check_evidence(&function.id, &function.evidence, &probes)?;
486 if function.kind.is_member() {
487 self.by_member
488 .insert(function.id.clone(), self.functions.len());
489 } else {
490 self.by_function
491 .insert(function.id.clone(), self.functions.len());
492 }
493 self.functions.push(function);
494 }
495
496 for alias in aliases {
499 if self.alias_by_source.contains_key(&alias.source) {
500 return Err(ManifestError(format!(
501 "duplicate alias source '{}'",
502 alias.source
503 )));
504 }
505 if self.by_function.contains_key(&alias.source)
506 || self.by_member.contains_key(&alias.source)
507 {
508 return Err(ManifestError(format!(
509 "alias source '{}' collides with a declared function",
510 alias.source
511 )));
512 }
513 match alias.kind {
514 AliasKind::FunctionAlias => {
515 if self.function(&alias.target).is_none() {
516 return Err(ManifestError(format!(
517 "alias '{}' targets '{}' which is not a generic function",
518 alias.source, alias.target
519 )));
520 }
521 }
522 AliasKind::MemberAlias => {
523 if self.member(&alias.target).is_none() {
524 return Err(ManifestError(format!(
525 "alias '{}' targets '{}' which is not a member function",
526 alias.source, alias.target
527 )));
528 }
529 }
530 }
531 self.check_evidence(&alias.source, &alias.evidence, &probes)?;
532 self.alias_by_source
533 .insert(alias.source.clone(), self.aliases.len());
534 self.aliases.push(alias);
535 }
536
537 Ok(())
538 }
539
540 fn check_evidence(
541 &self,
542 owner: &str,
543 evidence: &[String],
544 probes: &HashMap<&str, &Probe>,
545 ) -> Result<(), ManifestError> {
546 if evidence.is_empty() {
547 return Err(ManifestError(format!(
548 "entry '{owner}' records no oracle probe evidence"
549 )));
550 }
551 for probe_id in evidence {
552 let probe = probes.get(probe_id.as_str()).ok_or_else(|| {
553 ManifestError(format!(
554 "entry '{owner}' references undeclared probe '{probe_id}'"
555 ))
556 })?;
557 if probe.expect != "success" {
558 return Err(ManifestError(format!(
559 "entry '{owner}' references probe '{probe_id}' which does not record \
560 oracle acceptance"
561 )));
562 }
563 }
564 Ok(())
565 }
566
567 pub fn builtin() -> Result<&'static Manifest, ManifestError> {
569 static MANIFEST: OnceLock<Result<Manifest, ManifestError>> = OnceLock::new();
570 MANIFEST
571 .get_or_init(|| Manifest::load(MANIFEST_DATA, PROBES_DATA))
572 .as_ref()
573 .map_err(Clone::clone)
574 }
575
576 fn resolve_alias(&self, name: &str, kind: AliasKind) -> Option<&str> {
577 let alias = &self.aliases[*self.alias_by_source.get(name)?];
578 (alias.kind == kind).then_some(alias.target.as_str())
579 }
580
581 pub fn resolve_function(&self, name: &str) -> Option<&Function> {
583 self.function(name).or_else(|| {
584 self.resolve_alias(name, AliasKind::FunctionAlias)
585 .and_then(|target| self.function(target))
586 })
587 }
588
589 pub fn resolve_member(&self, name: &str) -> Option<&Function> {
591 self.member(name).or_else(|| {
592 self.resolve_alias(name, AliasKind::MemberAlias)
593 .and_then(|target| self.member(target))
594 })
595 }
596
597 pub fn function(&self, id: &str) -> Option<&Function> {
599 self.by_function.get(id).map(|i| &self.functions[*i])
600 }
601
602 pub fn member(&self, id: &str) -> Option<&Function> {
604 self.by_member.get(id).map(|i| &self.functions[*i])
605 }
606
607 pub fn domain_identity(&self, name: &str) -> bool {
613 self.domain_identities.contains(name)
614 }
615}
616
617pub fn canonicalize(manifest_json: &str, probes_json: &str) -> Result<String, ManifestError> {
622 Manifest::load(manifest_json, probes_json)?;
623 let value: serde_json::Value = serde_json::from_str(manifest_json)
624 .map_err(|error| ManifestError(format!("manifest data: {error}")))?;
625 serde_json::to_string_pretty(&value)
626 .map(|mut out| {
627 out.push('\n');
628 out
629 })
630 .map_err(|error| ManifestError(format!("cannot serialize manifest: {error}")))
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636
637 #[test]
638 fn builtin_manifest_loads_and_validates() {
639 let manifest = Manifest::builtin().expect("embedded manifest must validate");
640 assert_eq!(manifest.schema_version, 1);
641 assert_eq!(manifest.reference.name, "overpy");
642 assert_eq!(manifest.reference.version, "9.7.10");
643 assert_eq!(
644 manifest.reference.content_commit,
645 "889d9749d1def17f146548cbddb94ea1ab015847"
646 );
647 assert!(!manifest.functions.is_empty());
648 assert!(!manifest.aliases.is_empty());
649 for domain in ["Invis", "ChaseTimeReeval", "Team", "LosCheck", "Color"] {
654 assert!(manifest.domain_identity(domain), "{domain}");
655 }
656 assert_eq!(
657 manifest
658 .function("chase")
659 .expect("chase entry")
660 .catalog_link,
661 CatalogLink::SpecialLowering
662 );
663 assert_eq!(
664 manifest
665 .member("getHero")
666 .expect("getHero entry")
667 .catalog_link,
668 CatalogLink::Canonical
669 );
670 assert!(
671 !manifest.domain_identity("ChaseReeval"),
672 "contextual domains are not standalone identities"
673 );
674 for function in &manifest.functions {
675 assert!(!function.evidence.is_empty(), "{}", function.id);
676 if function.kind.is_member() {
677 assert!(function.receiver.is_some(), "{}", function.id);
678 }
679 }
680 }
681
682 #[test]
683 fn manifest_data_is_canonical() {
684 let canonical = canonicalize(MANIFEST_DATA, PROBES_DATA).expect("canonicalizes");
687 assert_eq!(canonical, MANIFEST_DATA, "manifest.json must be canonical");
688 assert_eq!(
690 canonicalize(&canonical, PROBES_DATA).expect("re-canonicalizes"),
691 canonical
692 );
693 }
694
695 #[test]
696 fn manifest_omits_contextual_lowering_policy() {
697 assert!(!MANIFEST_DATA.contains("\"contextualDomain\""));
698 assert!(!MANIFEST_DATA.contains("\"context\": \"forIterable\""));
699 }
700
701 #[test]
702 fn validation_rejects_duplicates_and_missing_evidence() {
703 fn mutate(mutate: impl FnOnce(&mut ManifestFile)) -> Result<Manifest, ManifestError> {
704 let mut file: ManifestFile = serde_json::from_str(MANIFEST_DATA).unwrap();
705 mutate(&mut file);
706 Manifest::load(&serde_json::to_string(&file).unwrap(), PROBES_DATA)
707 }
708 let error = mutate(|file| file.functions.push(file.functions[0].clone()))
710 .expect_err("duplicate function id must fail");
711 assert!(error.0.contains("duplicate function id"));
712 let error = mutate(|file| file.functions[0].catalog_link = CatalogLink::CatalogGap)
714 .expect_err("canonical catalog id must not carry a gap reason");
715 assert!(error.0.contains("catalogLink"));
716 let error = mutate(|file| file.functions[0].evidence.clear())
718 .expect_err("missing evidence must fail");
719 assert!(error.0.contains("no oracle probe evidence"));
720 let error = mutate(|file| {
723 file.functions[0].params.push(Param {
724 name: "bad".to_string(),
725 domain: None,
726 default: Some(ParamDefault::EnumMember("X".to_string())),
727 optional: false,
728 keyword_only: false,
729 positional_only: false,
730 alternate_names: Vec::new(),
731 variable: false,
732 })
733 })
734 .expect_err("enum default without a domain must fail");
735 assert!(error.0.contains("no declared domain"));
736 }
737
738 #[test]
739 fn arity_bounds_follow_defaults_and_unbounded() {
740 let manifest = Manifest::builtin().expect("builtin");
741 let chase = manifest.function("chaseOverTime").expect("entry");
742 assert_eq!(chase.arity_bounds(), (3, Some(4)));
743 let radius = manifest.function("getPlayersInRadius").expect("entry");
744 assert_eq!(radius.arity_bounds(), (2, Some(4)));
745 let status = manifest.member("setStatusEffect").expect("entry");
746 assert_eq!(status.arity_bounds(), (3, Some(3)));
747 let format = manifest.member("format").expect("entry");
748 assert_eq!(format.arity_bounds(), (0, None));
749 let range = manifest.function("range").expect("entry");
750 assert_eq!(range.arity_bounds(), (1, Some(3)));
751 assert_eq!(
752 crate::lower::policy::function_context(&range.id),
753 Some(crate::lower::policy::FunctionContext::ForIterable)
754 );
755 }
756
757 #[test]
758 fn aliases_resolve_to_declared_targets() {
759 let manifest = Manifest::builtin().expect("builtin");
760 let alias = manifest
761 .resolve_function("stopChasingVariable")
762 .expect("alias");
763 assert_eq!(alias.id, "stopChasingVariable");
764 assert!(alias.kind.is_action());
765 let member = manifest.resolve_member("getCurrentHero").expect("alias");
766 assert_eq!(member.id, "getHero");
767 assert!(member.kind.is_value());
768 assert!(manifest.resolve_function("frobnicate").is_none());
770 assert!(manifest.resolve_member("frobnicate").is_none());
771 }
772}