Skip to main content

tapes_client/core/models/
coverage.rs

1//! The schema-coverage gate.
2//!
3//! # What this gate is for
4//!
5//! [`crate::core::coverage`] catches an operation the contract grew and the
6//! client never exposed. This one catches the quieter half of the same failure:
7//! an operation whose *shape* grew — a field added to `SessionItem`, a schema
8//! added beside it — while the models kept decoding happily, dropping the new
9//! data on the floor. Nothing fails at runtime when that happens. The response
10//! still parses; the field is simply never seen again.
11//!
12//! So the models are held to the vendored document mechanically:
13//!
14//! 1. **Every schema is accounted for.** Modelled, or allow-listed with the
15//!    reason it is not — the same partition, and the same failure, as the
16//!    operation gate.
17//! 2. **Every property survives a round trip.** A document synthesised from the
18//!    schema is decoded into the model and re-serialised; anything the model
19//!    does not carry comes back missing, and is reported by path.
20//! 3. **The decoding rules hold.** A schema's optional properties really are
21//!    optional (the whole document decodes from `{}`), a required one really is
22//!    required, and a composite property really does tolerate `null` — see
23//!    [`super`] for why each of those matters.
24//!
25//! The synthesised document is the trick that makes this work without a
26//! hand-written description of each model. A hand-written one would be a second
27//! copy of the contract, kept by hand, which is the thing being prevented.
28//! Serde is the description: what the model can carry is exactly what survives
29//! decoding and re-encoding.
30//!
31//! # Why the tables live here and not with the consumer
32//!
33//! Deliberately the opposite of [`crate::core::coverage`], and for the same
34//! reason. Coverage of *operations* is a statement about one client's surface,
35//! so sharing the tables would make the gate report on a union and protect
36//! nobody. Coverage of *schemas* is a statement about these models, which ship
37//! in this crate — so the tables ship with them, and a consumer gets the gate
38//! by depending on the crate rather than by maintaining a copy of it.
39
40use 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
51/// A coverage table: schema name paired with prose for the reviewer.
52pub type Table<'a> = &'a [(&'a str, &'a str)];
53
54/// Schemas this crate deliberately does not model, and why.
55///
56/// The cassette surface models the discovery document itself — partially and on
57/// purpose, since a deployment's configuration is not part of the generated
58/// command surface. Modelling it a second time here is exactly the duplication
59/// this crate exists to end, so these are allow-listed rather than copied.
60pub 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/// One modelled schema, and the checks its model can be put through.
81#[derive(Clone, Copy)]
82pub struct Entry {
83    schema: &'static str,
84    run: fn(&Value, &Map<String, Value>) -> Vec<String>,
85}
86
87impl Entry {
88    /// Register one model against the schema it claims.
89    #[must_use]
90    pub fn of<M: ContractModel>() -> Self {
91        Self {
92            schema: M::SCHEMA,
93            run: audit::<M>,
94        }
95    }
96
97    /// The schema this entry covers.
98    #[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/// Every schema this crate models, in one table.
113///
114/// The registry is a function rather than a `const` so a model is registered by
115/// naming its type — `Entry::of::<SessionItem>()` — which cannot disagree with
116/// the type's own [`ContractModel::SCHEMA`] the way a repeated string could.
117#[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/// What a coverage run found wrong.
169///
170/// Every category is reported at once, because a gate that surfaces one problem
171/// per run turns a contract bump into a sequence of runs.
172#[derive(Debug, Default, PartialEq, Eq)]
173pub struct SchemaReport {
174    /// Schemas in the contract that are neither modelled nor allow-listed.
175    pub unmodelled: Vec<String>,
176    /// Schemas named by a table that the contract does not have.
177    pub stale: Vec<String>,
178    /// Schemas both modelled and allow-listed.
179    pub contradictory: Vec<String>,
180    /// Ways a model disagreed with the schema it claims.
181    pub disagreements: Vec<String>,
182}
183
184impl SchemaReport {
185    /// Whether the models and the contract agree.
186    #[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
229/// Compare this crate's models against the vendored contract's schemas.
230///
231/// Returns the report whether or not it is clean; [`check`] is the assertion
232/// form.
233///
234/// # Errors
235///
236/// Fails only when the vendored contract cannot be read at all, which this
237/// crate's contract tests catch long before.
238pub 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; // reported as stale below
251        };
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
280/// The assertion form of [`report`], over this crate's own tables.
281///
282/// # Errors
283///
284/// The rendered report, when the models and the contract disagree.
285pub fn check() -> std::result::Result<(), String> {
286    let report = report(&registry(), UNMODELLED).map_err(|error| error.to_string())?;
287    if report.is_clean() {
288        return Ok(());
289    }
290    Err(report.to_string())
291}
292
293/// Hold one operation's parameter type to the parameters the contract declares.
294///
295/// `params` must be fully populated: the check is two-directional, so a value
296/// left unset reads as a parameter the type cannot express.
297///
298/// # Errors
299///
300/// A message naming every parameter the type sends that the contract does not
301/// declare, and every non-path parameter the contract declares that the type
302/// cannot send.
303pub 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/// One Rust enum's claim on a contract-declared value set.
327#[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    /// Register one parameter enum.
335    #[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
344/// Hold the parameter enums to the value sets the contract closes.
345///
346/// Two-directional, like the schema gate: a value the contract added and the
347/// Rust enum lacks is unreachable from a typed call site, and a
348/// contract-declared set that no enum claims is a parameter still spelled by
349/// hand.
350///
351/// # Errors
352///
353/// A message naming every disagreement.
354pub 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
380/// The vendored document, parsed.
381fn document() -> Result<Value> {
382    serde_yaml::from_str(TAPES_API_YAML)
383        .ok()
384        .context(error::VendoredContractSnafu {
385            surface: "tapes-api",
386        })
387}
388
389/// The vendored document's `components.schemas`.
390fn 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
402/// One claim against one declaration, as a problem or nothing.
403fn 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
425/// The `enum` a document declares for one operation's parameter, if any.
426fn 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
433/// Every `(operation, parameter, values)` the document closes with an `enum`.
434fn 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
462/// `(operationId, path, parameters)` for every operation in the document.
463fn 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
494/// Put one model through every check the schema supports.
495fn 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    // 1. Everything the schema declares survives decode + re-encode.
501    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    // 2. Optional properties really are optional; required ones really are
512    //    required. The contract declares required-ness per schema, so this
513    //    reads it rather than assuming today's answer (which is "none").
514    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    // 3. A composite property tolerates an explicit null.
542    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
559/// Report every value that did not survive the round trip, by path.
560fn 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
587/// A schema's declared properties, resolving one level of `$ref`.
588fn 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
596/// Whether a property is one of the positions a `null` can legitimately arrive
597/// in: an array, a map, an object, or another schema.
598fn 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        // An untyped schema accepts anything, `null` included.
606        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
615/// Build a document that exercises every property a schema declares.
616///
617/// Values are chosen to be exactly representable after a JSON round trip, so a
618/// faithful model returns them unchanged and the comparison stays a statement
619/// about the model rather than about float formatting.
620fn sample(schema: &Value, schemas: &Map<String, Value>, depth: usize) -> Value {
621    // The document nests about ten deep at its worst (a listing, of sessions,
622    // of rollups, of per-model spend). The cap is well past that and exists
623    // only so a schema that ever references itself terminates — loudly, as a
624    // decode failure, rather than by recursing until the stack ends.
625    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        // The gate itself. A contract bump that adds a schema, adds a field to
678        // one, or changes a field's type fails here — at build time, where
679        // somebody can decide about it — rather than by quietly dropping data.
680        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        // The perturbation this gate exists to catch, pinned as a test rather
702        // than as a claim: a model missing one property of its schema names
703        // that property in the failure.
704        #[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        // The partial-update bodies omit an unset field from the wire, which is
727        // exactly what a dropped property looks like to the round trip. So the
728        // gate has to keep telling the two apart, and this is where that is
729        // pinned: the model below carries one of `updateSkillRequest`'s six
730        // properties as an omittable `Option` and simply lacks the rest. The
731        // one it models is populated by the sample and survives; the five it
732        // does not are reported by name, exactly as a model with five plain
733        // missing fields would be.
734        #[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        // The rule that keeps one nil projection from costing a caller the
787        // whole document.
788        #[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        // The contract requires nothing today. The rule is read from the
811        // document rather than assumed, so this exercises the branch that will
812        // matter the first time a schema does mark one.
813        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        // Two-directional, and the struct literals are exhaustive: a parameter
841        // added to the contract fails the check, and a field added to one of
842        // these structs fails the compile until it is decided about here.
843        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}