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