1use std::collections::BTreeSet;
41use std::fmt;
42
43use serde_json::{Map, Value, json};
44
45use super::ContractModel;
46use super::params::{ContractEnum, ContractParams};
47use crate::core::contract::{TAPES_API_YAML, core};
48use crate::error::{Result, error};
49use snafu::OptionExt;
50
51pub type Table<'a> = &'a [(&'a str, &'a str)];
53
54pub const UNMODELLED: Table<'static> = &[
61 (
62 "Discovery",
63 "modelled by the cassette surface, which reads only the fields it acts on",
64 ),
65 (
66 "DiscoveryEntry",
67 "part of the discovery document; see Discovery",
68 ),
69 (
70 "DiscoveryDepends",
71 "part of the discovery document; see Discovery",
72 ),
73 (
74 "DiscoverySetting",
75 "part of the discovery document; see Discovery",
76 ),
77 ("Rejection", "part of the discovery document; see Discovery"),
78 (
79 "AdvertisedEntity",
80 "part of the discovery document; see Discovery",
81 ),
82 (
83 "EntityRelation",
84 "part of the discovery document; see Discovery",
85 ),
86];
87
88#[derive(Clone, Copy)]
90pub struct Entry {
91 schema: &'static str,
92 run: fn(&Value, &Map<String, Value>) -> Vec<String>,
93}
94
95impl Entry {
96 #[must_use]
98 pub fn of<M: ContractModel>() -> Self {
99 Self {
100 schema: M::SCHEMA,
101 run: audit::<M>,
102 }
103 }
104
105 #[must_use]
107 pub fn schema(&self) -> &'static str {
108 self.schema
109 }
110}
111
112impl fmt::Debug for Entry {
113 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114 f.debug_struct("Entry")
115 .field("schema", &self.schema)
116 .finish()
117 }
118}
119
120#[must_use]
126pub fn registry() -> Vec<Entry> {
127 use super::{admin, protocol, raw_turn, session, span, trace};
128 vec![
129 Entry::of::<session::SessionItem>(),
130 Entry::of::<session::SessionRollup>(),
131 Entry::of::<session::SessionUsage>(),
132 Entry::of::<session::ModelUsage>(),
133 Entry::of::<session::TreeTask>(),
134 Entry::of::<session::SessionListResponse>(),
135 Entry::of::<session::SessionDetailResponse>(),
136 Entry::of::<session::SessionTracesResponse>(),
137 Entry::of::<session::SessionUpdateRequest>(),
138 Entry::of::<trace::TraceItem>(),
139 Entry::of::<trace::TraceUsage>(),
140 Entry::of::<trace::MainUsage>(),
141 Entry::of::<trace::TraceDetail>(),
142 Entry::of::<trace::StandaloneTraceDetail>(),
143 Entry::of::<trace::TraceListResponse>(),
144 Entry::of::<span::SpanItem>(),
145 Entry::of::<span::SpanLinkItem>(),
146 Entry::of::<raw_turn::RawTurnHeaderItem>(),
147 Entry::of::<raw_turn::RawTurnListResponse>(),
148 Entry::of::<raw_turn::RawTurnAttribution>(),
149 Entry::of::<raw_turn::RawTurnAttributionRepairRequest>(),
150 Entry::of::<raw_turn::RawTurnAttributionRepairResult>(),
151 Entry::of::<raw_turn::RepairPendingSession>(),
152 Entry::of::<admin::SeedResult>(),
153 Entry::of::<admin::SeedDemoRequest>(),
154 Entry::of::<admin::DeriveRunResponse>(),
155 Entry::of::<admin::RederiveReport>(),
156 Entry::of::<admin::ReconcileStats>(),
157 Entry::of::<admin::TranscriptProjectionStats>(),
158 Entry::of::<admin::StatsResponse>(),
159 Entry::of::<protocol::ErrorResponse>(),
160 Entry::of::<protocol::McpRequest>(),
161 Entry::of::<protocol::McpResponse>(),
162 Entry::of::<protocol::McpError>(),
163 ]
164}
165
166#[derive(Debug, Default, PartialEq, Eq)]
171pub struct SchemaReport {
172 pub unmodelled: Vec<String>,
174 pub stale: Vec<String>,
176 pub contradictory: Vec<String>,
178 pub disagreements: Vec<String>,
180}
181
182impl SchemaReport {
183 #[must_use]
185 pub fn is_clean(&self) -> bool {
186 self.unmodelled.is_empty()
187 && self.stale.is_empty()
188 && self.contradictory.is_empty()
189 && self.disagreements.is_empty()
190 }
191}
192
193impl fmt::Display for SchemaReport {
194 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195 if !self.unmodelled.is_empty() {
196 write!(
197 f,
198 "schemas in the vendored tapes-api contract that this crate neither models nor \
199 allow-lists: {:?} — add a model (and register it) or allow-list it with the \
200 reason it stays unmodelled. ",
201 self.unmodelled,
202 )?;
203 }
204 if !self.stale.is_empty() {
205 write!(
206 f,
207 "schemas named by a coverage table that the vendored contract does not have: \
208 {:?} — the contract dropped or renamed them, and the models must move in the \
209 same change. ",
210 self.stale,
211 )?;
212 }
213 if !self.contradictory.is_empty() {
214 write!(
215 f,
216 "schemas both modelled and allow-listed: {:?}. ",
217 self.contradictory
218 )?;
219 }
220 for disagreement in &self.disagreements {
221 write!(f, "{disagreement} ")?;
222 }
223 Ok(())
224 }
225}
226
227pub fn report(modelled: &[Entry], unmodelled: Table<'_>) -> Result<SchemaReport> {
237 let schemas = schemas()?;
238 let known: BTreeSet<&str> = schemas.keys().map(String::as_str).collect();
239 let modelled_ids: BTreeSet<&str> = modelled.iter().map(|entry| entry.schema).collect();
240 let unmodelled_ids: BTreeSet<&str> = unmodelled.iter().map(|(id, _)| *id).collect();
241
242 let owned =
243 |ids: BTreeSet<&str>| -> Vec<String> { ids.into_iter().map(ToOwned::to_owned).collect() };
244
245 let mut disagreements = Vec::new();
246 for entry in modelled {
247 let Some(schema) = schemas.get(entry.schema) else {
248 continue; };
250 disagreements.extend((entry.run)(schema, &schemas));
251 }
252
253 Ok(SchemaReport {
254 unmodelled: owned(
255 known
256 .iter()
257 .filter(|id| !modelled_ids.contains(*id) && !unmodelled_ids.contains(*id))
258 .copied()
259 .collect(),
260 ),
261 stale: owned(
262 modelled_ids
263 .union(&unmodelled_ids)
264 .filter(|id| !known.contains(*id))
265 .copied()
266 .collect(),
267 ),
268 contradictory: owned(
269 modelled_ids
270 .intersection(&unmodelled_ids)
271 .copied()
272 .collect(),
273 ),
274 disagreements,
275 })
276}
277
278pub fn check() -> std::result::Result<(), String> {
284 let report = report(®istry(), UNMODELLED).map_err(|error| error.to_string())?;
285 if report.is_clean() {
286 return Ok(());
287 }
288 Err(report.to_string())
289}
290
291pub fn check_params<P: ContractParams>(params: &P) -> std::result::Result<(), String> {
302 let surface = core().map_err(|error| error.to_string())?;
303 let method = surface.method(P::OPERATION).map_err(|e| e.to_string())?;
304 let declared: BTreeSet<&str> = method
305 .params
306 .iter()
307 .filter(|param| param.location != crate::cassettes::spec::Location::Path)
308 .map(|param| param.wire.as_str())
309 .collect();
310 let sent: BTreeSet<&str> = params.values().into_iter().map(|(wire, _)| wire).collect();
311
312 let undeclared: Vec<&str> = sent.difference(&declared).copied().collect();
313 let unsendable: Vec<&str> = declared.difference(&sent).copied().collect();
314 if undeclared.is_empty() && unsendable.is_empty() {
315 return Ok(());
316 }
317 Err(format!(
318 "{} parameters disagree with the contract: sends {undeclared:?} which the contract does \
319 not declare; cannot send {unsendable:?} which it does.",
320 P::OPERATION,
321 ))
322}
323
324#[derive(Debug, Clone, Copy)]
326pub struct ClaimedEnum {
327 declared_by: &'static [(&'static str, &'static str)],
328 values: &'static [&'static str],
329}
330
331impl ClaimedEnum {
332 #[must_use]
334 pub fn of<E: ContractEnum>() -> Self {
335 Self {
336 declared_by: E::DECLARED_BY,
337 values: E::VALUES,
338 }
339 }
340}
341
342pub fn check_enums(claimed: &[ClaimedEnum]) -> std::result::Result<(), String> {
353 let document = document().map_err(|error| error.to_string())?;
354 let mut problems = Vec::new();
355 let mut covered: BTreeSet<(&str, &str)> = BTreeSet::new();
356
357 for claim in claimed {
358 for (operation, parameter) in claim.declared_by {
359 covered.insert((operation, parameter));
360 problems.extend(compare_enum(&document, claim, operation, parameter));
361 }
362 }
363
364 for (operation, parameter, _) in every_declared_enum(&document) {
365 if !covered.contains(&(operation.as_str(), parameter.as_str())) {
366 problems.push(format!(
367 "{operation}'s {parameter} closes a value set that no typed enum claims."
368 ));
369 }
370 }
371
372 if problems.is_empty() {
373 return Ok(());
374 }
375 Err(problems.join(" "))
376}
377
378fn document() -> Result<Value> {
380 serde_yaml::from_str(TAPES_API_YAML)
381 .ok()
382 .context(error::VendoredContractSnafu {
383 surface: "tapes-api",
384 })
385}
386
387fn schemas() -> Result<Map<String, Value>> {
389 let document = document()?;
390 document
391 .get("components")
392 .and_then(|components| components.get("schemas"))
393 .and_then(Value::as_object)
394 .cloned()
395 .context(error::VendoredContractSnafu {
396 surface: "tapes-api",
397 })
398}
399
400fn compare_enum(
402 document: &Value,
403 claim: &ClaimedEnum,
404 operation: &str,
405 parameter: &str,
406) -> Option<String> {
407 let Some(values) = declared_enum(document, operation, parameter) else {
408 return Some(format!(
409 "{operation}'s {parameter} is claimed as a closed set, but the contract declares no \
410 enum for it."
411 ));
412 };
413 let ours: BTreeSet<&str> = claim.values.iter().copied().collect();
414 let theirs: BTreeSet<&str> = values.iter().map(String::as_str).collect();
415 if ours == theirs {
416 return None;
417 }
418 Some(format!(
419 "{operation}'s {parameter} accepts {theirs:?} but the typed enum offers {ours:?}."
420 ))
421}
422
423fn declared_enum(document: &Value, operation: &str, parameter: &str) -> Option<Vec<String>> {
425 every_declared_enum(document)
426 .into_iter()
427 .find(|(op, name, _)| op == operation && name == parameter)
428 .map(|(_, _, values)| values)
429}
430
431fn every_declared_enum(document: &Value) -> Vec<(String, String, Vec<String>)> {
433 let mut found = Vec::new();
434 for (operation, _, params) in operations(document) {
435 for param in params {
436 let Some(name) = param.get("name").and_then(Value::as_str) else {
437 continue;
438 };
439 let Some(values) = param
440 .get("schema")
441 .and_then(|schema| schema.get("enum"))
442 .and_then(Value::as_array)
443 else {
444 continue;
445 };
446 found.push((
447 operation.clone(),
448 name.to_owned(),
449 values
450 .iter()
451 .filter_map(Value::as_str)
452 .map(ToOwned::to_owned)
453 .collect(),
454 ));
455 }
456 }
457 found
458}
459
460fn operations(document: &Value) -> Vec<(String, String, Vec<Value>)> {
462 let mut found = Vec::new();
463 let Some(paths) = document.get("paths").and_then(Value::as_object) else {
464 return found;
465 };
466 for (path, item) in paths {
467 let Some(item) = item.as_object() else {
468 continue;
469 };
470 for operation in item.values() {
471 let Some(id) = operation_id(operation) else {
472 continue;
473 };
474 let params = operation
475 .get("parameters")
476 .and_then(Value::as_array)
477 .cloned()
478 .unwrap_or_default();
479 found.push((id, path.clone(), params));
480 }
481 }
482 found
483}
484
485fn operation_id(operation: &Value) -> Option<String> {
486 operation
487 .get("operationId")
488 .and_then(Value::as_str)
489 .map(ToOwned::to_owned)
490}
491
492fn audit<M: ContractModel>(schema: &Value, schemas: &Map<String, Value>) -> Vec<String> {
494 let name = M::SCHEMA;
495 let mut problems = Vec::new();
496 let populated = sample(schema, schemas, 0);
497
498 match serde_json::from_value::<M>(populated.clone()) {
500 Err(error) => problems.push(format!(
501 "{name} does not decode a document built from its own schema: {error}.",
502 )),
503 Ok(model) => match serde_json::to_value(&model) {
504 Err(error) => problems.push(format!("{name} does not re-encode: {error}.")),
505 Ok(encoded) => survived(name, &populated, &encoded, &mut problems),
506 },
507 }
508
509 let required: Vec<&str> = schema
513 .get("required")
514 .and_then(Value::as_array)
515 .map(|names| names.iter().filter_map(Value::as_str).collect())
516 .unwrap_or_default();
517 let mut minimal = Map::new();
518 for property in &required {
519 if let Some(value) = populated.get(*property) {
520 minimal.insert((*property).to_owned(), value.clone());
521 }
522 }
523 if serde_json::from_value::<M>(Value::Object(minimal)).is_err() {
524 problems.push(format!(
525 "{name} does not decode a document carrying only the properties the contract \
526 requires; an optional property is modelled as mandatory.",
527 ));
528 }
529 for property in &required {
530 let mut without = populated.as_object().cloned().unwrap_or_default();
531 without.remove(*property);
532 if serde_json::from_value::<M>(Value::Object(without)).is_ok() {
533 problems.push(format!(
534 "{name}.{property} is required by the contract but decodes when absent.",
535 ));
536 }
537 }
538
539 for (property, declared) in properties(schema) {
541 if !is_composite(declared) {
542 continue;
543 }
544 let mut nulled = populated.as_object().cloned().unwrap_or_default();
545 nulled.insert(property.clone(), Value::Null);
546 if serde_json::from_value::<M>(Value::Object(nulled)).is_err() {
547 problems.push(format!(
548 "{name}.{property} does not tolerate a null; a nil map, slice, or struct pointer \
549 the server did not omit would blank the whole response.",
550 ));
551 }
552 }
553
554 problems
555}
556
557fn survived(path: &str, sent: &Value, back: &Value, problems: &mut Vec<String>) {
559 match (sent, back) {
560 (Value::Object(sent), Value::Object(back)) => {
561 for (key, value) in sent {
562 match back.get(key) {
563 None => problems.push(format!(
564 "{path}.{key} is in the contract but not carried by the model.",
565 )),
566 Some(got) => survived(&format!("{path}.{key}"), value, got, problems),
567 }
568 }
569 }
570 (Value::Array(sent), Value::Array(back)) => {
571 for (index, value) in sent.iter().enumerate() {
572 match back.get(index) {
573 None => problems.push(format!("{path}[{index}] was dropped by the model.")),
574 Some(got) => survived(&format!("{path}[{index}]"), value, got, problems),
575 }
576 }
577 }
578 (sent, back) if sent != back => {
579 problems.push(format!("{path} decoded as {back} rather than {sent}."));
580 }
581 _ => {}
582 }
583}
584
585fn properties(schema: &Value) -> Vec<(String, &Value)> {
587 schema
588 .get("properties")
589 .and_then(Value::as_object)
590 .map(|props| props.iter().map(|(k, v)| (k.clone(), v)).collect())
591 .unwrap_or_default()
592}
593
594fn is_composite(schema: &Value) -> bool {
597 if schema.get("$ref").is_some() {
598 return true;
599 }
600 match schema.get("type").and_then(Value::as_str) {
601 Some("array" | "object") => true,
602 Some(_) => false,
603 None => true,
605 }
606}
607
608fn resolve<'a>(schema: &Value, schemas: &'a Map<String, Value>) -> Option<&'a Value> {
609 let name = schema.get("$ref")?.as_str()?.rsplit('/').next()?;
610 schemas.get(name)
611}
612
613fn sample(schema: &Value, schemas: &Map<String, Value>, depth: usize) -> Value {
619 if depth > 24 {
624 return Value::Null;
625 }
626 if let Some(target) = resolve(schema, schemas) {
627 return sample(target, schemas, depth + 1);
628 }
629 match schema.get("type").and_then(Value::as_str) {
630 Some("string") => match schema.get("format").and_then(Value::as_str) {
631 Some("date-time") => json!("2020-01-02T03:04:05Z"),
632 _ => json!("sample"),
633 },
634 Some("boolean") => json!(true),
635 Some("integer") => json!(1),
636 Some("number") => json!(1.5),
637 Some("array") => {
638 let items = schema.get("items").cloned().unwrap_or_else(|| json!({}));
639 json!([sample(&items, schemas, depth + 1)])
640 }
641 Some("object") | None => {
642 if let Some(props) = schema.get("properties").and_then(Value::as_object) {
643 let mut object = Map::new();
644 for (name, declared) in props {
645 object.insert(name.clone(), sample(declared, schemas, depth + 1));
646 }
647 return Value::Object(object);
648 }
649 match schema.get("additionalProperties") {
650 Some(additional) if additional.as_object().is_some_and(Map::is_empty) => {
651 json!({"key": "sample"})
652 }
653 Some(additional) => json!({"key": sample(additional, schemas, depth + 1)}),
654 None if schema.get("type").is_none() => json!("sample"),
655 None => json!({}),
656 }
657 }
658 Some(_) => json!("sample"),
659 }
660}
661
662#[cfg(test)]
663#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
664mod tests {
665 use super::*;
666 use crate::core::models::params::{
667 PayloadDetail, RawTurnListParams, SessionListParams, SessionTracesParams, SortDirection,
668 StatsParams, TraceListParams, TraceParams,
669 };
670 use serde::{Deserialize, Serialize};
671
672 #[test]
673 fn the_models_cover_the_vendored_contracts_schemas() {
674 assert_eq!(check(), Ok(()));
678 }
679
680 #[test]
681 fn a_schema_in_neither_table_is_reported_as_unmodelled() {
682 let report = report(&[Entry::of::<super::super::SessionItem>()], &[]).unwrap();
683 assert!(!report.is_clean());
684 assert!(
685 report.unmodelled.contains(&"SpanItem".to_owned()),
686 "got: {report:?}",
687 );
688 }
689
690 #[test]
691 fn a_table_entry_the_contract_does_not_have_is_reported_as_stale() {
692 let report = report(&[], &[("LaunchCodes", "nowhere")]).unwrap();
693 assert_eq!(report.stale, vec!["LaunchCodes".to_owned()]);
694 }
695
696 #[test]
697 fn a_model_that_drops_a_contract_field_is_reported_by_path() {
698 #[derive(Debug, Default, Serialize, Deserialize)]
702 #[serde(default)]
703 struct HalfASession {
704 id: String,
705 }
706 impl ContractModel for HalfASession {
707 const SCHEMA: &'static str = "SessionItem";
708 }
709
710 let report = report(&[Entry::of::<HalfASession>()], UNMODELLED).unwrap();
711 assert!(
712 report
713 .disagreements
714 .iter()
715 .any(|problem| problem.contains("SessionItem.display_title")
716 && problem.contains("not carried by the model")),
717 "got: {report:?}",
718 );
719 }
720
721 #[test]
722 fn an_omittable_field_still_has_to_carry_its_property() {
723 let schema = json!({
731 "type": "object",
732 "properties": {
733 "name": {"type": "string"},
734 "description": {"type": "string"},
735 },
736 });
737 let schemas = Map::new();
738
739 #[derive(Debug, Default, Serialize, Deserialize)]
740 #[serde(default)]
741 struct HalfAnUpdate {
742 #[serde(skip_serializing_if = "Option::is_none")]
743 name: Option<String>,
744 }
745 impl ContractModel for HalfAnUpdate {
746 const SCHEMA: &'static str = "Synthetic";
747 }
748
749 let problems = audit::<HalfAnUpdate>(&schema, &schemas);
750 assert!(
751 problems.iter().any(|problem| {
752 problem.contains("Synthetic.description")
753 && problem.contains("not carried by the model")
754 }),
755 "the dropped property must be reported; got: {problems:?}",
756 );
757 assert!(
758 !problems
759 .iter()
760 .any(|problem| problem.contains("Synthetic.name")),
761 "an Option field the sample populates is carried, not missing; got: {problems:?}",
762 );
763 }
764
765 #[test]
766 fn a_model_that_mistypes_a_field_is_reported_as_a_decode_failure() {
767 #[derive(Debug, Default, Serialize, Deserialize)]
768 #[serde(default)]
769 struct MistypedUsage {
770 input_tokens: String,
771 }
772 impl ContractModel for MistypedUsage {
773 const SCHEMA: &'static str = "SessionUsage";
774 }
775
776 let report = report(&[Entry::of::<MistypedUsage>()], UNMODELLED).unwrap();
777 assert!(
778 report
779 .disagreements
780 .iter()
781 .any(|problem| problem.contains("does not decode a document built from its own")),
782 "got: {report:?}",
783 );
784 }
785
786 #[test]
787 fn a_composite_that_refuses_a_null_is_reported() {
788 #[derive(Debug, Default, Serialize, Deserialize)]
791 #[serde(default)]
792 struct StrictItems {
793 items: Vec<Value>,
794 }
795 impl ContractModel for StrictItems {
796 const SCHEMA: &'static str = "RawTurnListResponse";
797 }
798
799 let report = report(&[Entry::of::<StrictItems>()], UNMODELLED).unwrap();
800 assert!(
801 report
802 .disagreements
803 .iter()
804 .any(|problem| problem.contains("RawTurnListResponse.items")
805 && problem.contains("does not tolerate a null")),
806 "got: {report:?}",
807 );
808 }
809
810 #[test]
811 fn a_required_property_modelled_as_optional_is_reported() {
812 let schema = json!({
818 "type": "object",
819 "required": ["id"],
820 "properties": {"id": {"type": "string"}},
821 });
822 let schemas = Map::new();
823
824 #[derive(Debug, Default, Serialize, Deserialize)]
825 #[serde(default)]
826 struct Lenient {
827 id: String,
828 }
829 impl ContractModel for Lenient {
830 const SCHEMA: &'static str = "Synthetic";
831 }
832
833 let problems = audit::<Lenient>(&schema, &schemas);
834 assert!(
835 problems
836 .iter()
837 .any(|problem| problem.contains("required by the contract but decodes when absent")),
838 "got: {problems:?}",
839 );
840 }
841
842 #[test]
843 fn every_typed_parameter_set_matches_the_contracts_declaration() {
844 check_params(&SessionListParams {
848 limit: Some(1),
849 cursor: Some("c".to_owned()),
850 sort: Some("last_active".to_owned()),
851 direction: Some(SortDirection::Desc),
852 since: Some("2020-01-01T00:00:00Z".to_owned()),
853 until: Some("2020-01-02T00:00:00Z".to_owned()),
854 harness_id: Some("claude".to_owned()),
855 harness_session_id: Some("hs-1".to_owned()),
856 auth_subject: Some("user".to_owned()),
857 claimed: vec![("flavor".to_owned(), "grape".to_owned())],
861 })
862 .unwrap();
863 check_params(&SessionTracesParams {
864 payload: Some(PayloadDetail::Full),
865 limit: Some(50),
866 cursor: Some("c".to_owned()),
867 })
868 .unwrap();
869 check_params(&TraceParams {
870 payload: Some(PayloadDetail::Preview),
871 limit: Some(200),
872 cursor: Some("c".to_owned()),
873 })
874 .unwrap();
875 check_params(&RawTurnListParams {
876 limit: Some(200),
877 cursor: Some("c".to_owned()),
878 })
879 .unwrap();
880 check_params(&TraceListParams {
881 session_id: "s-1".to_owned(),
882 })
883 .unwrap();
884 check_params(&StatsParams {
885 since: Some("2020-01-01T00:00:00Z".to_owned()),
886 until: Some("2020-01-02T00:00:00Z".to_owned()),
887 auth_subject: Some("user".to_owned()),
888 })
889 .unwrap();
890 }
891
892 #[test]
893 fn a_parameter_the_contract_does_not_declare_is_reported() {
894 struct Typo;
895 impl ContractParams for Typo {
896 const OPERATION: &'static str = "getSessionTraces";
897 fn values(&self) -> Vec<(&'static str, String)> {
898 vec![("payolad", "full".to_owned())]
899 }
900 }
901 let err = check_params(&Typo).unwrap_err();
902 assert!(err.contains("payolad"), "got: {err}");
903 }
904
905 #[test]
906 fn every_closed_value_set_in_the_contract_has_a_typed_enum() {
907 assert_eq!(
908 check_enums(&[
909 ClaimedEnum::of::<PayloadDetail>(),
910 ClaimedEnum::of::<SortDirection>(),
911 ]),
912 Ok(())
913 );
914 }
915
916 #[test]
917 fn a_value_set_no_typed_enum_claims_is_reported() {
918 let err = check_enums(&[]).unwrap_err();
919 assert!(err.contains("no typed enum claims"), "got: {err}");
920 }
921}