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