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