1use std::collections::HashSet;
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::catalog::{Catalog, CatalogEntry, EnumDomain, Kind, Locale};
13use crate::conformance::{
14 CONFORMANCE_SCHEMA_VERSION, Comparison, ConformanceReason, ConformanceResult,
15 ConformanceStatus, Equivalence, Evidence, EvidenceArtifact, EvidenceBasis, EvidenceClass,
16 ExpectationSource, FeatureId, FeatureKind, FeatureNamespace, ImplementationIdentity,
17 ReasonCode,
18};
19use crate::convert;
20use crate::emitter;
21use crate::error::WorkshopError;
22use crate::parser;
23use crate::roundtrip;
24use crate::settings::table::{self, KeyKind, PathPart, TableEntry};
25use crate::wir::{CENSUS_CAPABILITIES, CensusCapabilityKind};
26
27pub const CENSUS_SCHEMA_VERSION: u32 = 1;
28pub const CENSUS_IDENTITY_SCHEMA_VERSION: u32 = 1;
29const EN_US: &str = "en-US";
30const ZH_CN: &str = "zh-CN";
31const CENSUS_TRACKING_REF: &str = "#19";
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(tag = "kind", rename_all = "kebab-case")]
36pub enum CensusSupport {
37 Exercise,
38 Unsupported {
39 detail: String,
40 },
41 KnownGap {
42 detail: String,
43 tracking_ref: String,
44 },
45 Inconclusive {
46 detail: String,
47 },
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct CensusCase {
53 pub case_id: String,
54 pub features: Vec<FeatureId>,
55 #[serde(default = "default_source_locale")]
58 pub source_locale: String,
59 pub source: String,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub reference_source: Option<String>,
64 pub support: CensusSupport,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct CensusShard {
70 pub shard_id: String,
71 pub cases: Vec<CensusCase>,
72}
73
74impl CensusShard {
75 pub fn new(
76 shard_id: impl Into<String>,
77 mut cases: Vec<CensusCase>,
78 ) -> Result<Self, CensusError> {
79 let shard_id = shard_id.into();
80 validate_name("shard_id", &shard_id)?;
81 cases.sort_by(|left, right| left.case_id.cmp(&right.case_id));
82 for case in &cases {
83 case.validate()?;
84 }
85 if cases
86 .windows(2)
87 .any(|pair| pair[0].case_id == pair[1].case_id)
88 {
89 return Err(CensusError::new(format!(
90 "shard '{shard_id}' contains duplicate case IDs"
91 )));
92 }
93 Ok(Self { shard_id, cases })
94 }
95}
96
97impl CensusCase {
98 fn validate(&self) -> Result<(), CensusError> {
99 validate_name("case_id", &self.case_id)?;
100 if self.features.is_empty() {
101 return Err(CensusError::new(format!(
102 "case '{}' has no feature IDs",
103 self.case_id
104 )));
105 }
106 if self.source.trim().is_empty() {
107 return Err(CensusError::new(format!(
108 "case '{}' has no source",
109 self.case_id
110 )));
111 }
112 validate_name("source_locale", &self.source_locale)?;
113 if self
114 .reference_source
115 .as_deref()
116 .is_some_and(|source| source.trim().is_empty())
117 {
118 return Err(CensusError::new(format!(
119 "case '{}' has an empty reference source",
120 self.case_id
121 )));
122 }
123 let mut features = HashSet::new();
124 if self
125 .features
126 .iter()
127 .any(|feature| !features.insert(feature))
128 {
129 return Err(CensusError::new(format!(
130 "case '{}' contains duplicate feature IDs",
131 self.case_id
132 )));
133 }
134 Ok(())
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Census {
141 shards: Vec<CensusShard>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct CensusIdentity {
148 pub schema_version: u32,
149 pub digest: String,
150 pub shards: Vec<String>,
151}
152
153impl Census {
154 pub fn assemble(mut shards: Vec<CensusShard>) -> Result<Self, CensusError> {
156 shards.sort_by(|left, right| left.shard_id.cmp(&right.shard_id));
157 let mut shard_ids = HashSet::new();
158 let mut case_ids = HashSet::new();
159 for shard in &shards {
160 if !shard_ids.insert(shard.shard_id.clone()) {
161 return Err(CensusError::new(format!(
162 "duplicate census shard '{}'",
163 shard.shard_id
164 )));
165 }
166 for case in &shard.cases {
167 if !case_ids.insert(case.case_id.clone()) {
168 return Err(CensusError::new(format!(
169 "duplicate census case '{}'",
170 case.case_id
171 )));
172 }
173 }
174 }
175 Ok(Self { shards })
176 }
177
178 pub fn builtin(catalog: &Catalog) -> Result<Self, CensusError> {
181 Self::assemble(vec![
182 catalog_shard(catalog, Kind::Event, "catalog-events")?,
183 catalog_shard(catalog, Kind::Action, "catalog-actions")?,
184 catalog_shard(catalog, Kind::Value, "catalog-values")?,
185 catalog_shard(catalog, Kind::Operator, "catalog-operators")?,
186 catalog_shard(catalog, Kind::Structural, "catalog-structural")?,
187 enum_shard(catalog)?,
188 settings_shard()?,
189 wir_shard()?,
190 localization_shard()?,
191 content_id_shard(catalog)?,
192 ])
193 }
194
195 pub fn shards(&self) -> &[CensusShard] {
196 &self.shards
197 }
198
199 pub fn cases(&self) -> impl Iterator<Item = &CensusCase> {
200 self.shards.iter().flat_map(|shard| shard.cases.iter())
201 }
202
203 pub fn run(&self, catalog: &Catalog) -> CensusReport {
205 let mut results: Vec<_> = self
206 .shards
207 .iter()
208 .flat_map(|shard| {
209 shard
210 .cases
211 .iter()
212 .map(move |case| run_case(case, &shard.shard_id, catalog))
213 })
214 .collect();
215 results.sort_by(|left, right| left.case_id.cmp(&right.case_id));
216 CensusReport {
217 schema_version: CENSUS_SCHEMA_VERSION,
218 conformance_schema_version: CONFORMANCE_SCHEMA_VERSION,
219 catalog: catalog.identity(),
220 census: self.identity(),
221 results,
222 }
223 }
224
225 pub fn identity(&self) -> CensusIdentity {
227 let definition = self
228 .export_json()
229 .expect("census definitions must remain serializable");
230 CensusIdentity {
231 schema_version: CENSUS_IDENTITY_SCHEMA_VERSION,
232 digest: sha256(&definition),
233 shards: self
234 .shards
235 .iter()
236 .map(|shard| shard.shard_id.clone())
237 .collect(),
238 }
239 }
240
241 pub fn export_json(&self) -> Result<String, CensusError> {
243 serde_json::to_string_pretty(&self.shards)
244 .map_err(|error| CensusError::new(format!("cannot serialize census shards: {error}")))
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "camelCase")]
251pub struct CensusReport {
252 pub schema_version: u32,
253 pub conformance_schema_version: u32,
254 pub catalog: crate::catalog::CatalogIdentity,
255 pub census: CensusIdentity,
256 pub results: Vec<ConformanceResult>,
257}
258
259impl CensusReport {
260 pub fn validate(&self) -> Result<(), CensusError> {
261 let catalog =
262 Catalog::builtin().map_err(|error| CensusError::new(format!("catalog: {error}")))?;
263 self.validate_against(&catalog)
264 }
265
266 pub fn validate_against(&self, catalog: &Catalog) -> Result<(), CensusError> {
267 if self.schema_version != CENSUS_SCHEMA_VERSION {
268 return Err(CensusError::new("unsupported census schema version"));
269 }
270 if self.conformance_schema_version != CONFORMANCE_SCHEMA_VERSION {
271 return Err(CensusError::new("unsupported conformance schema version"));
272 }
273 if self.catalog != catalog.identity() {
274 return Err(CensusError::new(
275 "report catalog identity does not match the loaded catalog",
276 ));
277 }
278 if self.census.schema_version != CENSUS_IDENTITY_SCHEMA_VERSION {
279 return Err(CensusError::new(
280 "unsupported census identity schema version",
281 ));
282 }
283 if self.census.digest.len() != 64
284 || !self
285 .census
286 .digest
287 .chars()
288 .all(|character| character.is_ascii_hexdigit())
289 {
290 return Err(CensusError::new(
291 "census identity digest must be a SHA-256 hex digest",
292 ));
293 }
294 if self.census.shards.is_empty()
295 || self.census.shards.windows(2).any(|pair| pair[0] >= pair[1])
296 {
297 return Err(CensusError::new(
298 "report shards must be non-empty and strictly sorted",
299 ));
300 }
301 for result in &self.results {
302 result
303 .validate_against(catalog)
304 .map_err(|error| CensusError::new(error.to_string()))?;
305 let matching_shards = self
306 .census
307 .shards
308 .iter()
309 .filter(|shard| {
310 result
311 .case_id
312 .strip_prefix(shard.as_str())
313 .is_some_and(|rest| rest.starts_with('/'))
314 })
315 .count();
316 if matching_shards != 1 {
317 return Err(CensusError::new(format!(
318 "result '{}' does not map to exactly one census shard",
319 result.case_id
320 )));
321 }
322 }
323 Ok(())
324 }
325
326 pub fn to_json(&self) -> Result<String, CensusError> {
327 self.validate()?;
328 serde_json::to_string_pretty(self)
329 .map_err(|error| CensusError::new(format!("cannot serialize census report: {error}")))
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct CensusError {
335 pub message: String,
336}
337
338impl CensusError {
339 fn new(message: impl Into<String>) -> Self {
340 Self {
341 message: message.into(),
342 }
343 }
344}
345
346impl std::fmt::Display for CensusError {
347 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348 formatter.write_str(&self.message)
349 }
350}
351
352impl std::error::Error for CensusError {}
353
354fn validate_name(field: &str, value: &str) -> Result<(), CensusError> {
355 if value.trim().is_empty() || value.chars().any(char::is_control) {
356 Err(CensusError::new(format!(
357 "{field} must be non-empty and printable"
358 )))
359 } else {
360 Ok(())
361 }
362}
363
364fn default_source_locale() -> String {
365 EN_US.to_string()
366}
367
368fn feature(namespace: FeatureNamespace, kind: FeatureKind, name: impl Into<String>) -> FeatureId {
369 FeatureId::owned(namespace, kind, name).expect("canonical census feature ID")
370}
371
372fn catalog_feature(kind: Kind, id: &str) -> FeatureId {
373 FeatureId::from_catalog(kind, id).expect("catalog IDs are validated by Catalog::load")
374}
375
376fn catalog_shard(
377 catalog: &Catalog,
378 kind: Kind,
379 shard_id: &str,
380) -> Result<CensusShard, CensusError> {
381 let cases = catalog
382 .entries_of(kind)
383 .map(|entry| {
384 let source = match kind {
385 Kind::Event => event_probe(catalog, entry),
386 Kind::Action => action_probe(catalog, entry),
387 Kind::Value => value_probe(catalog, entry),
388 Kind::Operator => operator_probe(catalog, entry),
389 Kind::Structural => structural_probe(catalog, entry),
390 Kind::Setting => unreachable!("settings use the settings table"),
391 Kind::Enum => unreachable!("enum domains use the enum shard"),
392 };
393 CensusCase {
394 case_id: format!("{shard_id}/{}", entry.id),
395 features: vec![catalog_feature(kind, &entry.id)],
396 source_locale: EN_US.to_string(),
397 source,
398 reference_source: None,
399 support: generated_probe_support(),
400 }
401 })
402 .collect();
403 CensusShard::new(shard_id, cases)
404}
405
406fn enum_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
407 let mut cases = Vec::new();
408 for domain in catalog.enum_domains() {
409 for member in &domain.members {
410 let features = vec![
411 catalog_feature(Kind::Enum, &domain.domain),
412 FeatureId::from_enum_member(&domain.domain, &member.member)
413 .expect("canonical enum member ID"),
414 ];
415 cases.push(CensusCase {
416 case_id: format!("catalog-enums/{}/{}", domain.domain, member.member),
417 features,
418 source_locale: EN_US.to_string(),
419 source: enum_probe(catalog, domain, &member.member),
420 reference_source: None,
421 support: generated_probe_support(),
422 });
423 }
424 }
425 CensusShard::new("catalog-enums", cases)
426}
427
428fn content_id_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
429 let mut cases = Vec::new();
430 for domain in catalog.enum_domains() {
431 if !matches!(domain.domain.as_str(), "Hero" | "Map") {
432 continue;
433 }
434 for member in &domain.members {
435 cases.push(CensusCase {
436 case_id: format!("content-ids/{}/{}", domain.domain, member.member),
437 features: vec![
438 FeatureId::from_enum_member(&domain.domain, &member.member)
439 .expect("canonical content enum-member ID"),
440 ],
441 source_locale: EN_US.to_string(),
442 source: enum_probe(catalog, domain, &member.member),
443 reference_source: None,
444 support: generated_probe_support(),
445 });
446 }
447 }
448 CensusShard::new("content-ids", cases)
449}
450
451fn settings_shard() -> Result<CensusShard, CensusError> {
452 let cases = table::ENTRIES
453 .iter()
454 .map(|entry| {
455 let path = table::path_string(entry.path);
456 CensusCase {
457 case_id: format!("settings/{path}"),
458 features: vec![feature(
459 FeatureNamespace::Settings,
460 FeatureKind::Setting,
461 path,
462 )],
463 source_locale: EN_US.to_string(),
464 source: settings_probe(entry),
465 reference_source: None,
466 support: generated_probe_support(),
467 }
468 })
469 .collect();
470 CensusShard::new("settings", cases)
471}
472
473fn wir_shard() -> Result<CensusShard, CensusError> {
474 let cases = CENSUS_CAPABILITIES
475 .iter()
476 .map(|capability| match capability.kind {
477 CensusCapabilityKind::Variable => wir_case(
478 "variables-global",
479 FeatureKind::Variable,
480 capability.name,
481 variables_source(),
482 ),
483 CensusCapabilityKind::PlayerVariable => CensusCase {
484 case_id: "wir/variables-player".to_string(),
485 features: vec![feature(
486 FeatureNamespace::Wir,
487 FeatureKind::Variable,
488 capability.name,
489 )],
490 source_locale: EN_US.to_string(),
491 source: player_variable_source(),
492 reference_source: None,
493 support: generated_probe_support(),
494 },
495 CensusCapabilityKind::Subroutine => wir_case(
496 "subroutine",
497 FeatureKind::Subroutine,
498 capability.name,
499 subroutine_source(),
500 ),
501 CensusCapabilityKind::ControlFlow => {
502 let actions = match capability.name {
503 "if" => "If(True);\n Wait(0);\nEnd;",
504 "else-if" => "If(True);\n Wait(0);\nElse If(False);\n Wait(0);\nEnd;",
505 "else" => "If(True);\n Wait(0);\nElse;\n Wait(0);\nEnd;",
506 "while" => "While(True);\n Wait(0);\nEnd;",
507 "for-global-variable" => {
508 "For Global Variable(probe, 0, 1, 1);\n Wait(0);\nEnd;"
509 }
510 _ => unreachable!("unknown WIR control-flow census capability"),
511 };
512 control_flow_case(capability.name, actions)
513 }
514 CensusCapabilityKind::String => CensusCase {
515 case_id: "wir/string/custom-string".to_string(),
516 features: vec![feature(
517 FeatureNamespace::Wir,
518 FeatureKind::String,
519 capability.name,
520 )],
521 source_locale: EN_US.to_string(),
522 source: rule_source(
523 "String",
524 "Set Global Variable(probe, Custom String(\"census\"));",
525 ),
526 reference_source: None,
527 support: CensusSupport::Exercise,
528 },
529 })
530 .collect();
531 CensusShard::new("wir", cases)
532}
533
534fn localization_shard() -> Result<CensusShard, CensusError> {
535 let en_source = include_str!("../tests/fixtures/census/localization-en-us.ws").to_string();
536 let zh_source = include_str!("../tests/fixtures/census/localization-zh-cn.ws").to_string();
537 CensusShard::new(
538 "localization",
539 vec![
540 CensusCase {
541 case_id: "localization/en-us-to-zh-cn".to_string(),
542 features: vec![feature(
543 FeatureNamespace::Localization,
544 FeatureKind::Localization,
545 "en-us-to-zh-cn",
546 )],
547 source_locale: EN_US.to_string(),
548 source: en_source,
549 reference_source: None,
550 support: generated_probe_support(),
551 },
552 CensusCase {
553 case_id: "localization/zh-cn-to-en-us".to_string(),
554 features: vec![feature(
555 FeatureNamespace::Localization,
556 FeatureKind::Localization,
557 "zh-cn-to-en-us",
558 )],
559 source_locale: ZH_CN.to_string(),
560 source: zh_source,
561 reference_source: None,
562 support: generated_probe_support(),
563 },
564 ],
565 )
566}
567
568fn wir_case(case_id: &str, kind: FeatureKind, name: &str, source: String) -> CensusCase {
569 CensusCase {
570 case_id: format!("wir/{case_id}"),
571 features: vec![feature(FeatureNamespace::Wir, kind, name)],
572 source_locale: EN_US.to_string(),
573 source,
574 reference_source: None,
575 support: CensusSupport::Exercise,
576 }
577}
578
579fn control_flow_case(name: &str, actions: &str) -> CensusCase {
580 CensusCase {
581 case_id: format!("wir/control-flow/{name}"),
582 features: vec![feature(
583 FeatureNamespace::Wir,
584 FeatureKind::ControlFlow,
585 name,
586 )],
587 source_locale: EN_US.to_string(),
588 source: rule_source(name, actions),
589 reference_source: None,
590 support: generated_probe_support(),
591 }
592}
593
594fn generated_probe_support() -> CensusSupport {
595 CensusSupport::Inconclusive {
596 detail: "generated probe is exportable for independent Workshop/client evidence but has no independently recorded expected result".to_string(),
597 }
598}
599
600fn rule_source(name: &str, actions: &str) -> String {
601 format!(
602 "variables {{\n global:\n 0: probe\n}}\n\nrule (\"{name}\") {{\n event {{\n Ongoing - Global;\n }}\n actions {{\n {actions}\n }}\n}}\n"
603 )
604}
605
606fn variables_source() -> String {
607 "variables {\n global:\n 0: probe\n}\n\nrule (\"Global variable\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(probe, 1);\n }\n}\n"
608 .to_string()
609}
610
611fn player_variable_source() -> String {
612 "variables {\n player:\n 0: probe\n}\n\nrule (\"Player variable\") {\n event {\n Ongoing - Each Player;\n All;\n All;\n }\n actions {\n Set Player Variable(Event Player, probe, 1);\n }\n}\n"
613 .to_string()
614}
615
616fn subroutine_source() -> String {
617 "subroutines {\n 0: probe\n}\n\nrule (\"Subroutine\") {\n event {\n Subroutine;\n probe;\n }\n actions {\n Call Subroutine(probe);\n }\n}\n"
618 .to_string()
619}
620
621fn event_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
622 let spelling = catalog
623 .spelling(Kind::Event, &Locale::new(EN_US), &entry.id)
624 .unwrap_or(&entry.id);
625 let filters = if matches!(entry.id.as_str(), "global" | "subroutine") {
626 String::new()
627 } else {
628 " All;\n All;\n".to_string()
629 };
630 let subroutine = if entry.id == "subroutine" {
631 " probe;\n"
632 } else {
633 ""
634 };
635 format!(
636 "subroutines {{\n 0: probe\n}}\n\nrule (\"Event\") {{\n event {{\n {spelling};\n{filters}{subroutine} }}\n actions {{\n Wait;\n }}\n}}\n"
637 )
638}
639
640fn action_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
641 let spelling = catalog
642 .spelling(Kind::Action, &Locale::new(EN_US), &entry.id)
643 .unwrap_or(&entry.id);
644 let call = if matches!(
645 entry.id.as_str(),
646 "chasePlayerVariableAtRate" | "chasePlayerVariableOverTime"
647 ) {
648 format!("{spelling}(Event Player, probe, 0, 1, 0);")
649 } else {
650 format!("{spelling};")
651 };
652 rule_source("Action", &call)
653}
654
655fn value_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
656 let spelling = catalog
657 .spelling(Kind::Value, &Locale::new(EN_US), &entry.id)
658 .unwrap_or(&entry.id);
659 rule_source("Value", &format!("Set Global Variable(probe, {spelling});"))
660}
661
662fn operator_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
663 let spelling = catalog
664 .spelling(Kind::Operator, &Locale::new(EN_US), &entry.id)
665 .unwrap_or(&entry.id);
666 rule_source(
667 "Operator",
668 &format!("If(1 {spelling} 1);\n Wait(0);\nEnd;"),
669 )
670}
671
672fn structural_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
673 let spelling = catalog
674 .spelling(Kind::Structural, &Locale::new(EN_US), &entry.id)
675 .unwrap_or(&entry.id);
676 let actions = match entry.id.as_str() {
677 "if" => format!("{spelling}(True);\n Wait(0);\nEnd;"),
678 "elseIf" => format!("If(True);\n Wait(0);\n{spelling}(False);\n Wait(0);\nEnd;"),
679 "else" => format!("If(True);\n Wait(0);\n{spelling};\n Wait(0);\nEnd;"),
680 "end" => format!("If(True);\n Wait(0);\n{spelling};"),
681 "while" => format!("{spelling}(True);\n Wait(0);\nEnd;"),
682 "forGlobalVariable" => format!("{spelling}(probe, 0, 1, 1);\n Wait(0);\nEnd;"),
683 "setGlobalVariable" => format!("{spelling}(probe, 1);"),
684 "modifyGlobalVariable" => format!("{spelling}(probe, Add, 1);"),
685 "setPlayerVariable" => format!("{spelling}(Event Player, probe, 1);"),
686 "modifyPlayerVariable" => format!("{spelling}(Event Player, probe, Add, 1);"),
687 "callSubroutine" => format!("{spelling}(probe);"),
688 _ => format!("{spelling};"),
689 };
690 let prefix = match entry.id.as_str() {
691 "setPlayerVariable" | "modifyPlayerVariable" => {
692 "variables {\n player:\n 0: probe\n}\n\n"
693 }
694 "callSubroutine" => "subroutines {\n 0: probe\n}\n\n",
695 _ => "",
696 };
697 format!("{prefix}{}", rule_source("Structural", &actions))
698}
699
700fn enum_probe(catalog: &Catalog, domain: &EnumDomain, member: &str) -> String {
701 let locale = Locale::new(EN_US);
702 let domain_spelling = catalog
703 .spelling(Kind::Value, &locale, &domain.domain)
704 .unwrap_or(&domain.domain);
705 let member_spelling = catalog
706 .enum_spelling(&domain.domain, &locale, member)
707 .unwrap_or(member);
708 rule_source(
709 "Enum",
710 &format!("Set Global Variable(probe, {domain_spelling}({member_spelling}));"),
711 )
712}
713
714fn settings_probe(entry: &TableEntry) -> String {
715 let mut lines = vec!["settings {".to_string()];
716 let mut depth = 1;
717 for part in entry.path {
718 let name = match part {
719 PathPart::Part("gamemodes") => "modes",
720 PathPart::Part("heroes") => "heroes",
721 PathPart::Part("main") => "main",
722 PathPart::Part("lobby") => "lobby",
723 PathPart::Part(value) => table::mode_name(value).unwrap_or(value),
724 PathPart::Team => "General",
725 PathPart::Hero => "Mei",
726 };
727 lines.push(format!("{}{} {{", " ".repeat(depth), name));
728 depth += 1;
729 }
730 let indent = " ".repeat(depth);
731 match entry.kind {
732 KeyKind::Flag => lines.push(format!("{indent}{}", entry.workshop_name)),
733 KeyKind::String => lines.push(format!("{indent}{}: \"census\"", entry.workshop_name)),
734 KeyKind::Bool => lines.push(format!("{indent}{}: On", entry.workshop_name)),
735 KeyKind::Number => lines.push(format!("{indent}{}: 1", entry.workshop_name)),
736 KeyKind::Percent => lines.push(format!("{indent}{}: 100%", entry.workshop_name)),
737 KeyKind::Enum(domain) => {
738 let member = if domain == "roleLimit" {
739 "2OfEachRolePerTeam"
740 } else {
741 "off"
742 };
743 let value = table::enum_name(domain, member).unwrap_or("Off");
744 lines.push(format!("{indent}{}: {value}", entry.workshop_name));
745 }
746 KeyKind::ListMap | KeyKind::ListHero => {
747 lines.push(format!("{indent}{} {{", entry.workshop_name));
748 lines.push(format!("{indent}}}"));
749 }
750 }
751 while depth > 1 {
752 depth -= 1;
753 lines.push(format!("{}{}", " ".repeat(depth), "}"));
754 }
755 lines.push("}".to_string());
756 lines.join("\n")
757}
758
759fn run_case(case: &CensusCase, shard_id: &str, catalog: &Catalog) -> ConformanceResult {
760 let fixture = artifact(
761 format!("census/{shard_id}/{}.ws", case.case_id),
762 &case.source,
763 );
764 let expectation = case
765 .reference_source
766 .as_ref()
767 .map(|source| ExpectationSource {
768 basis: EvidenceBasis::PreservedRegression,
769 artifact: reference_artifact(case, source),
770 tracking_ref: Some(CENSUS_TRACKING_REF.to_string()),
771 })
772 .unwrap_or_else(|| ExpectationSource {
773 basis: EvidenceBasis::SemanticContract,
774 artifact: EvidenceArtifact {
775 name: "docs/adr/0002-conformance-contract.md".to_string(),
776 revision: Some("ADR-0002".to_string()),
777 path: Some("docs/adr/0002-conformance-contract.md".to_string()),
778 sha256: None,
779 license: Some("MIT".to_string()),
780 },
781 tracking_ref: None,
782 });
783 let evidence = |locale: Option<Locale>| Evidence {
784 class: EvidenceClass::Synthetic,
785 fixture: fixture.clone(),
786 expectation: expectation.clone(),
787 catalog: catalog.identity(),
788 locale,
789 client: None,
790 implementation: Some(ImplementationIdentity {
791 name: "workshop-rs".to_string(),
792 version: Catalog::implementation_version().to_string(),
793 revision: None,
794 artifact: None,
795 }),
796 };
797 let base = |status, comparison, reason, locale| ConformanceResult {
798 schema_version: CONFORMANCE_SCHEMA_VERSION,
799 case_id: case.case_id.clone(),
800 features: case.features.clone(),
801 status,
802 comparison,
803 evidence: evidence(locale),
804 reason,
805 };
806 match &case.support {
807 CensusSupport::Unsupported { detail } => base(
808 ConformanceStatus::Unsupported,
809 not_comparable(),
810 Some(reason(ReasonCode::Unsupported, detail, None)),
811 None,
812 ),
813 CensusSupport::KnownGap {
814 detail,
815 tracking_ref,
816 } => base(
817 ConformanceStatus::KnownGap,
818 not_comparable(),
819 Some(reason(
820 ReasonCode::KnownGap,
821 detail,
822 Some(tracking_ref.clone()),
823 )),
824 None,
825 ),
826 CensusSupport::Inconclusive { detail } => execute_case(case, base, catalog, Some(detail)),
827 CensusSupport::Exercise => execute_case(case, base, catalog, None),
828 }
829}
830
831fn execute_case(
832 case: &CensusCase,
833 base: impl Fn(
834 ConformanceStatus,
835 Comparison,
836 Option<ConformanceReason>,
837 Option<Locale>,
838 ) -> ConformanceResult,
839 catalog: &Catalog,
840 inconclusive_detail: Option<&str>,
841) -> ConformanceResult {
842 let source_locale = Locale::new(&case.source_locale);
843 let target_locale = if source_locale.as_str() == Locale::new(ZH_CN).as_str() {
844 Locale::new(EN_US)
845 } else {
846 Locale::new(ZH_CN)
847 };
848 let program = match parser::parse_with_context(&case.source, catalog, &source_locale, catalog) {
849 Ok(program) => program,
850 Err(error) => return failed(base, &error, &source_locale),
851 };
852 if let Err(error) = program.validate() {
853 return failed_text(
854 base,
855 ReasonCode::UnexpectedRegression,
856 error.to_string(),
857 Some(source_locale.clone()),
858 );
859 }
860 let emitted_source = match emitter::emit(&program, catalog, &source_locale) {
861 Ok(output) => output,
862 Err(error) => return failed(base, &error, &source_locale),
863 };
864 let reparsed_source =
865 match parser::parse_with_context(&emitted_source, catalog, &source_locale, catalog) {
866 Ok(program) => program,
867 Err(error) => return failed(base, &error, &source_locale),
868 };
869 if let Err(error) = reparsed_source.validate() {
870 return failed_text(
871 base,
872 ReasonCode::UnexpectedRegression,
873 error.to_string(),
874 Some(source_locale.clone()),
875 );
876 }
877 let emitted_source_again = match emitter::emit(&reparsed_source, catalog, &source_locale) {
878 Ok(output) => output,
879 Err(error) => return failed(base, &error, &source_locale),
880 };
881 if !roundtrip::equivalent(&program, &reparsed_source)
882 || normalize_workshop(&emitted_source) != normalize_workshop(&emitted_source_again)
883 {
884 return failed_text(
885 base,
886 ReasonCode::UnexpectedRegression,
887 "en-US semantic or normalized gate diverged".to_string(),
888 Some(source_locale.clone()),
889 );
890 }
891 let converted = match convert::convert(
892 &case.source,
893 catalog,
894 &source_locale,
895 &target_locale,
896 &Default::default(),
897 ) {
898 Ok(output) => output,
899 Err(error) => return failed(base, &error, &target_locale),
900 };
901 let program_target =
902 match parser::parse_with_context(&converted.text, catalog, &target_locale, catalog) {
903 Ok(program) => program,
904 Err(error) => return failed(base, &error, &target_locale),
905 };
906 if let Err(error) = program_target.validate() {
907 return failed_text(
908 base,
909 ReasonCode::UnexpectedRegression,
910 error.to_string(),
911 Some(target_locale.clone()),
912 );
913 }
914 if !roundtrip::equivalent(&program, &program_target) {
915 return failed_text(
916 base,
917 ReasonCode::UnexpectedRegression,
918 "zh-CN conversion changed canonical WIR semantics".to_string(),
919 Some(target_locale.clone()),
920 );
921 }
922 let back_to_source = match convert::convert(
923 &converted.text,
924 catalog,
925 &target_locale,
926 &source_locale,
927 &Default::default(),
928 ) {
929 Ok(output) => output,
930 Err(error) => return failed(base, &error, &source_locale),
931 };
932 let reparsed_back =
933 match parser::parse_with_context(&back_to_source.text, catalog, &source_locale, catalog) {
934 Ok(program) => program,
935 Err(error) => return failed(base, &error, &source_locale),
936 };
937 if !roundtrip::equivalent(&program, &reparsed_back)
938 || normalize_workshop(&back_to_source.text) != normalize_workshop(&case.source)
939 {
940 return failed_text(
941 base,
942 ReasonCode::UnexpectedRegression,
943 "cross-locale semantic or normalized gate diverged".to_string(),
944 Some(source_locale.clone()),
945 );
946 }
947 let Some(reference_source) = case.reference_source.as_ref() else {
948 return failed_text(
949 base,
950 ReasonCode::Inconclusive,
951 inconclusive_detail.unwrap_or(
952 "offline semantic and locale gates passed, but no independent expectation artifact is recorded",
953 )
954 .to_string(),
955 Some(source_locale),
956 );
957 };
958 let expected_program =
959 match parser::parse_with_context(reference_source, catalog, &target_locale, catalog) {
960 Ok(program) => program,
961 Err(error) => return failed(base, &error, &target_locale),
962 };
963 if !roundtrip::equivalent(&program, &expected_program)
964 || normalize_workshop(&converted.text) != normalize_workshop(reference_source)
965 {
966 return failed_text(
967 base,
968 ReasonCode::UnexpectedRegression,
969 "conversion differed from the independent reference source".to_string(),
970 Some(target_locale),
971 );
972 }
973 base(
974 ConformanceStatus::Matched,
975 Comparison {
976 mode: Equivalence::Semantic,
977 expected: Some(reference_artifact(case, reference_source)),
978 observed: Some(artifact(
979 format!("census/{}/converted-output.ws", case.case_id),
980 &converted.text,
981 )),
982 normalizer: Some("canonical-wir;normalized-workshop-text".to_string()),
983 },
984 None,
985 Some(source_locale),
986 )
987}
988
989fn failed(
990 base: impl Fn(
991 ConformanceStatus,
992 Comparison,
993 Option<ConformanceReason>,
994 Option<Locale>,
995 ) -> ConformanceResult,
996 error: &WorkshopError,
997 locale: &Locale,
998) -> ConformanceResult {
999 let code = match error {
1000 WorkshopError::Unsupported { .. } => ReasonCode::Unsupported,
1001 WorkshopError::MissingMapping { .. } => ReasonCode::KnownGap,
1002 _ => ReasonCode::UnexpectedRegression,
1003 };
1004 failed_text(base, code, error.to_string(), Some(locale.clone()))
1005}
1006
1007fn failed_text(
1008 base: impl Fn(
1009 ConformanceStatus,
1010 Comparison,
1011 Option<ConformanceReason>,
1012 Option<Locale>,
1013 ) -> ConformanceResult,
1014 code: ReasonCode,
1015 detail: String,
1016 locale: Option<Locale>,
1017) -> ConformanceResult {
1018 let status = match code {
1019 ReasonCode::Unsupported => ConformanceStatus::Unsupported,
1020 ReasonCode::KnownGap => ConformanceStatus::KnownGap,
1021 ReasonCode::UnexpectedRegression => ConformanceStatus::UnexpectedRegression,
1022 ReasonCode::Inconclusive => ConformanceStatus::Inconclusive,
1023 };
1024 let comparison = if code == ReasonCode::UnexpectedRegression {
1025 Comparison {
1026 mode: Equivalence::Normalized,
1027 expected: None,
1028 observed: None,
1029 normalizer: Some("census-stage".to_string()),
1030 }
1031 } else {
1032 not_comparable()
1033 };
1034 let tracking = (code == ReasonCode::KnownGap).then(|| CENSUS_TRACKING_REF.to_string());
1035 base(
1036 status,
1037 comparison,
1038 Some(reason(code, &detail, tracking)),
1039 locale,
1040 )
1041}
1042
1043fn reason(code: ReasonCode, detail: &str, tracking_ref: Option<String>) -> ConformanceReason {
1044 ConformanceReason {
1045 code,
1046 detail: detail.to_string(),
1047 tracking_ref,
1048 }
1049}
1050
1051fn not_comparable() -> Comparison {
1052 Comparison {
1053 mode: Equivalence::NotComparable,
1054 expected: None,
1055 observed: None,
1056 normalizer: None,
1057 }
1058}
1059
1060fn artifact(name: impl Into<String>, content: &str) -> EvidenceArtifact {
1061 EvidenceArtifact {
1062 name: name.into(),
1063 revision: None,
1064 path: None,
1065 sha256: Some(sha256(content)),
1066 license: Some("MIT".to_string()),
1067 }
1068}
1069
1070fn reference_artifact(case: &CensusCase, content: &str) -> EvidenceArtifact {
1071 EvidenceArtifact {
1072 name: format!("census reference for {}", case.case_id),
1073 revision: Some("census-v1".to_string()),
1074 path: Some("tests/fixtures/census/reference.ws".to_string()),
1075 sha256: Some(sha256(content)),
1076 license: Some("MIT".to_string()),
1077 }
1078}
1079
1080fn sha256(content: &str) -> String {
1081 let mut hasher = Sha256::new();
1082 hasher.update(content.as_bytes());
1083 format!("{:x}", hasher.finalize())
1084}
1085
1086fn normalize_workshop(text: &str) -> String {
1087 text.split_whitespace().collect::<Vec<_>>().join(" ")
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093
1094 #[test]
1095 fn builtin_census_is_derived_and_deterministic() {
1096 let catalog = Catalog::builtin().expect("builtin catalog");
1097 let first = Census::builtin(&catalog).expect("census");
1098 let second = Census::builtin(&catalog).expect("census");
1099 assert_eq!(first, second);
1100 assert_eq!(first.shards().first().unwrap().shard_id, "catalog-actions");
1101 assert!(
1102 first
1103 .cases()
1104 .any(|case| case.features.iter().any(|feature| feature.name == "wait"))
1105 );
1106 assert!(first.cases().any(|case| {
1107 case.features
1108 .iter()
1109 .any(|feature| feature.kind == FeatureKind::Setting)
1110 }));
1111 assert!(first.cases().any(|case| {
1112 case.features
1113 .iter()
1114 .any(|feature| feature.kind == FeatureKind::ControlFlow)
1115 }));
1116 assert_eq!(first.export_json().unwrap(), second.export_json().unwrap());
1117 assert_eq!(first.identity(), second.identity());
1118 assert_eq!(first.identity().digest.len(), 64);
1119 }
1120
1121 #[test]
1122 fn explicit_non_matching_states_remain_machine_readable() {
1123 let feature_case = |id: &str, support| CensusCase {
1124 case_id: format!("state-tests/{id}"),
1125 features: vec![feature(FeatureNamespace::Wir, FeatureKind::Structural, id)],
1126 source_locale: EN_US.to_string(),
1127 source: format!("rule (\"{id}\") {{}}"),
1128 reference_source: None,
1129 support,
1130 };
1131 let shard = CensusShard::new(
1132 "state-tests",
1133 vec![
1134 feature_case(
1135 "unsupported",
1136 CensusSupport::Unsupported {
1137 detail: "not declared".to_string(),
1138 },
1139 ),
1140 feature_case(
1141 "known-gap",
1142 CensusSupport::KnownGap {
1143 detail: "missing mapping".to_string(),
1144 tracking_ref: "#19".to_string(),
1145 },
1146 ),
1147 feature_case(
1148 "inconclusive",
1149 CensusSupport::Inconclusive {
1150 detail: "no oracle".to_string(),
1151 },
1152 ),
1153 ],
1154 )
1155 .unwrap();
1156 let report = Census::assemble(vec![shard])
1157 .unwrap()
1158 .run(&Catalog::builtin().unwrap());
1159 report
1160 .validate()
1161 .expect("states use the current #18 adapter");
1162 let json = report.to_json().unwrap();
1163 assert!(json.contains("unsupported"));
1164 assert!(json.contains("known-gap"));
1165 assert!(json.contains("inconclusive"));
1166 }
1167
1168 #[test]
1169 fn builtin_census_report_validates_against_the_catalog() {
1170 let catalog = Catalog::builtin().expect("builtin catalog");
1171 let census = Census::builtin(&catalog).expect("census");
1172 let report = census.run(&catalog);
1173 report
1174 .validate_against(&catalog)
1175 .expect("census results use canonical catalog identities");
1176 assert_eq!(report.census, census.identity());
1177 assert_eq!(
1178 report
1179 .results
1180 .iter()
1181 .filter(|result| result.case_id.starts_with("localization/"))
1182 .count(),
1183 2
1184 );
1185 }
1186
1187 #[test]
1188 fn census_report_rejects_a_malformed_identity_digest() {
1189 let catalog = Catalog::builtin().expect("builtin catalog");
1190 let census = Census::builtin(&catalog).expect("census");
1191 let mut report = census.run(&catalog);
1192 report.census.digest = "not-a-digest".to_string();
1193
1194 let error = report
1195 .validate_against(&catalog)
1196 .expect_err("report identity must carry a SHA-256 digest");
1197 assert!(error.to_string().contains("SHA-256"));
1198 }
1199}