Skip to main content

taskfast_codegen/
lib.rs

1// SPDX-License-Identifier: MIT
2// TODO: doc the public surface; taskfast-codegen is published but internal-priority so keep this deferred.
3#![allow(missing_docs, clippy::doc_markdown)]
4
5//! TaskFast OpenAPI spec tooling.
6//!
7//! The authoritative spec lives at `spec/openapi.yaml`. The platform team
8//! evolves it against Elixir routes; clients generate from it. A small set of
9//! structural redundancies would cause progenitor to emit distinct Rust types
10//! for schemas that are semantically identical (e.g. multiple `{error, message}`
11//! objects under different names). This crate's [`normalize_spec`] folds those
12//! aliases into a single canonical shape *in memory*, leaving the on-disk spec
13//! untouched.
14//!
15//! Consumers:
16//!  - `cargo xtask sync-spec` (binary) — writes a normalized artifact for inspection.
17//!  - `taskfast-client/build.rs` — pipes the normalized YAML into progenitor.
18//!
19//! ## Rewrite rules
20//!
21//! Today: every name in [`ERROR_ALIASES`] is a structural clone of
22//! `#/components/schemas/Error`. The normalizer:
23//!   1. Asserts each alias is byte-for-byte structurally equal to `Error`
24//!      (ignoring `description`/`example` doc-only fields) — drift fails loud.
25//!   2. Rewrites every `$ref: '#/components/schemas/<alias>'` to point at
26//!      `#/components/schemas/Error`.
27//!   3. Removes the alias definitions from `components.schemas`.
28//!
29//! Adding a new alias ⇒ append to [`ERROR_ALIASES`]. If a schema grows a real
30//! distinguishing field, remove it from [`ERROR_ALIASES`]; the drift check
31//! will refuse to normalize otherwise.
32//!
33//! ## Multipart strip
34//!
35//! progenitor 0.9 can't codegen `multipart/form-data` request bodies. Rather
36//! than maintain a patched fork, the normalizer drops operations whose request
37//! body declares only multipart variants — today that is
38//! `POST /tasks/{task_id}/artifacts` (uploadArtifact). The upload path is
39//! hand-rolled in `taskfast-client` using `reqwest::multipart` directly; the
40//! Artifact *response* schema still comes through codegen because it's
41//! referenced by the surviving `GET /tasks/{task_id}/artifacts` operation.
42//! The removed operationIds are reported via [`Report::stripped_operations`]
43//! so callers can verify nothing unexpected disappeared.
44//!
45//! ## Multi-media-type request collapse
46//!
47//! progenitor 0.x panics (`more media types than expected`) when a request
48//! body declares more than one `content` media type. The OAuth endpoints
49//! (`POST /oauth2/token`, `POST /oauth2/revoke`) accept both
50//! `application/json` and `application/x-www-form-urlencoded`, each pointing
51//! at the *same* request schema. The normalizer collapses such bodies to a
52//! single preferred media type (`application/json` when present, else the
53//! first declared), which is lossless here because the variants share a
54//! schema. Runs *after* the multipart strip so multipart-bearing operations
55//! are already gone and never get collapsed instead of removed. Count surfaced
56//! via [`Report::request_media_collapsed`].
57//!
58//! ## Error response strip
59//!
60//! progenitor 0.9 asserts `response_types.len() <= 1` for both the success
61//! and error response sets (see `progenitor-impl/src/method.rs` —
62//! `extract_responses`). Nearly every TaskFast operation declares 2–5
63//! distinct error response shapes (`Error`, `ValidationError`, `ClaimFailure`,
64//! etc.), which trips the error-side assertion. Rather than synthesize a
65//! union error type that we'd throw away anyway, the normalizer drops every
66//! non-2xx (and non-`default`) response before handing the spec to progenitor.
67//! Surfacing of 4xx/5xx failures is the job of `taskfast-client::errors::Error`,
68//! which reads the response body manually on the way up.
69//!
70//! This means the generated client sees every unhappy status as
71//! `progenitor_client::Error::UnexpectedResponse(reqwest::Response)` — we
72//! re-classify into our typed `taskfast_client::errors::Error` in the calling layer.
73//!
74//! ## Closed-object strip
75//!
76//! Every `additionalProperties: false` is removed so generated types ignore
77//! unknown keys — an additive server change must not break installed CLIs
78//! (gh#172).
79//!
80//! ## Response-enum strip
81//!
82//! String enums reachable from success or `default` response bodies are opened
83//! so additive server enum values decode in released CLIs (gh#176). Request-only
84//! enums stay typed; shared schemas prioritize response tolerance.
85//!
86//! ## Response-required relax
87//!
88//! `required` lists in response-reachable schemas are cut down to
89//! [`KEEP_REQUIRED`], the fields CLI code reads as plain values. Every other
90//! response property becomes `Option`, so a server that drops a required field
91//! the CLI never reads no longer breaks released CLIs (gh#178).
92
93use std::collections::BTreeSet;
94
95use anyhow::{anyhow, bail, Context, Result};
96use serde_yaml::{Mapping, Value};
97
98/// Schemas known to be structural clones of `#/components/schemas/Error`.
99///
100/// Empty since the server's `Error` schema grew optional envelope fields
101/// (`details`, `status`, `fallback`, `errors`) that the minimal
102/// `WalletBalanceNotFoundError` / `WebhookNoWebhookError` shapes don't carry.
103/// Those two are still `{error, message}`-only and are referenced solely from
104/// non-2xx responses, which the error-response strip pass removes before
105/// progenitor — so they no longer need folding. Re-add a name here if a future
106/// schema is a byte-for-byte structural clone of `Error`.
107pub const ERROR_ALIASES: &[&str] = &[];
108
109const NULL_VARIANT_SCHEMA_KEYS: &[&str] = &["oneOf", "anyOf"];
110
111/// Response properties that stay required: `(schema key, property)`.
112///
113/// The compiler maintains most of this list. Every other response property
114/// becomes `Option`, and a CLI read of it as a plain value fails to compile;
115/// add the pair here when the CLI needs the value, so decode keeps failing fast
116/// at the HTTP boundary if the server stops sending it. Handling it as `Option`
117/// at the read site instead is a behavior decision: a missing money field must
118/// not default to zero. A few entries are semantic, not compiler-found; their
119/// comments say why.
120///
121/// The schema key names where the property is declared, not the generated Rust
122/// type: a component name (`AgentProfile`), a nested inline object under it
123/// (`AgentReadiness.settlement_domain`), or an `allOf` member
124/// (`TaskDetail.allOf.1`). The private `schema_key` builds these keys.
125pub const KEEP_REQUIRED: &[(&str, &str)] = &[
126    // taskfast-agent events stream: paging needs the cursor envelope.
127    ("AgentEventListResponse", "meta"),
128    ("PaginatedMeta", "has_more"),
129    // Semantic: the events stream already tolerates bad events one by one
130    // (events::decode_raw_page puts them in `unparseable` with the raw JSON).
131    // An event without its id or type can't be acked or dispatched, so it must
132    // keep landing there instead of decoding with `None`s.
133    ("AgentEvent", "data"),
134    ("AgentEvent", "event"),
135    ("AgentEvent", "event_id"),
136    ("AgentEvent", "occurred_at"),
137    // cmd/escrow.rs: ArbitratedEscrow deposit and poster-approval parameters.
138    ("BidEscrowParamsResponse", "amount"),
139    ("BidEscrowParamsResponse", "arbitrator_address"),
140    ("BidEscrowParamsResponse", "chain_id"),
141    ("BidEscrowParamsResponse", "decimals"),
142    ("BidEscrowParamsResponse", "platform_fee_amount"),
143    ("BidEscrowParamsResponse", "platform_wallet"),
144    ("BidEscrowParamsResponse", "poster_approval_deadline"),
145    ("BidEscrowParamsResponse", "poster_approval_min_lifetime"),
146    ("BidEscrowParamsResponse", "task_escrow_contract"),
147    ("BidEscrowParamsResponse", "task_id"),
148    ("BidEscrowParamsResponse", "token_address"),
149    ("BidEscrowParamsResponse", "worker_address"),
150    // cmd/bond.rs: stake amount.
151    ("getStakeQuote.200", "required_amount"),
152    // cmd/post.rs: submission-fee signing and draft submit.
153    ("TaskDraftPrepareResponse", "draft_id"),
154    ("TaskDraftPrepareResponse", "payload_to_sign"),
155    ("TaskDraftPrepareResponse", "token_address"),
156    ("TaskDraftSubmitResponse", "id"),
157    ("TaskDraftSubmitResponse", "status"),
158    // cmd/settle.rs: EIP-712 settlement domain.
159    ("AgentReadiness", "settlement_domain"),
160    ("AgentReadiness.settlement_domain", "chain_id"),
161    ("AgentReadiness.settlement_domain", "verifying_contract"),
162    ("TaskDetail.allOf.1.settlement_domain", "chain_id"),
163    ("TaskDetail.allOf.1.settlement_domain", "verifying_contract"),
164    // cmd/init.rs: readiness checks and webhook config.
165    ("AgentReadiness", "checks"),
166    ("AgentReadiness.checks", "funded"),
167    ("AgentReadiness.checks", "wallet"),
168    ("AgentReadinessCheck", "status"),
169    ("WebhookConfig", "url"),
170    // cmd/task.rs: uploaded artifact ids.
171    ("Artifact", "id"),
172];
173
174/// Normalize an OpenAPI YAML document: collapse [`ERROR_ALIASES`] into `Error`.
175///
176/// Returns the rewritten YAML as a string. Idempotent: running it on already
177/// normalized output is a no-op (aliases are absent, so nothing rewrites).
178pub fn normalize_spec(yaml: &str) -> Result<String> {
179    let (out, report) = normalize_with_aliases(yaml, ERROR_ALIASES)?;
180    tracing_nothing(&report); // suppress unused_variable warning when no tracing is wired
181    Ok(out)
182}
183
184/// As [`normalize_spec`] but also returns a summary of what changed.
185pub fn normalize_spec_with_report(yaml: &str) -> Result<(String, Report)> {
186    normalize_with_aliases(yaml, ERROR_ALIASES)
187}
188
189/// Core of [`normalize_spec`], parameterized on the alias list. Production
190/// callers pass [`ERROR_ALIASES`] (currently empty); the unit tests pass an
191/// explicit list so the fold and drift-check passes stay covered regardless of
192/// what the live spec needs today.
193fn normalize_with_aliases(yaml: &str, aliases: &[&str]) -> Result<(String, Report)> {
194    let mut doc: Value = serde_yaml::from_str(yaml).context("parse spec YAML")?;
195    let report = normalize_in_place(&mut doc, aliases)?;
196    let out = serde_yaml::to_string(&doc).context("re-serialize normalized spec")?;
197    Ok((out, report))
198}
199
200#[derive(Debug, Default, Clone)]
201pub struct Report {
202    /// Aliases that were folded into `Error`. Populated only for aliases that
203    /// were actually present in the input — missing aliases are silently skipped.
204    pub folded_aliases: Vec<String>,
205    /// Count of `$ref` rewrites performed across the document.
206    pub refs_rewritten: usize,
207    /// `operationId`s stripped because their only request-body variant was
208    /// multipart/form-data (progenitor 0.9 limitation).
209    pub stripped_operations: Vec<String>,
210    /// Count of non-2xx response entries removed across all operations
211    /// (progenitor 0.9 `response_types.len() <= 1` assertion).
212    pub error_responses_stripped: usize,
213    /// Count of `{type: "null"}` variants stripped from `oneOf` / `anyOf`
214    /// arrays anywhere in the document (progenitor 0.9 doesn't support
215    /// the OpenAPI 3.1 nullable-via-null-type pattern).
216    pub null_variants_stripped: usize,
217    /// Count of request bodies whose multiple `content` media types were
218    /// collapsed to a single preferred type (progenitor 0.x can't pick among
219    /// `>1` request media types — "more media types than expected").
220    pub request_media_collapsed: usize,
221    /// Count of string `enum` keywords removed from response-reachable schemas.
222    pub response_enums_stripped: usize,
223    /// Count of response properties dropped from `required` lists (not in
224    /// [`KEEP_REQUIRED`]).
225    pub response_required_relaxed: usize,
226}
227
228fn normalize_in_place(doc: &mut Value, aliases: &[&str]) -> Result<Report> {
229    let mut report = Report::default();
230
231    let schemas_path = ["components", "schemas"];
232    let canonical_shape = {
233        let error = get_mapping_path(doc, &schemas_path)
234            .and_then(|m| m.get(Value::from("Error")))
235            .ok_or_else(|| anyhow!("canonical schema `Error` not found in components.schemas"))?;
236        structural_shape(error)
237    };
238
239    // Pass 1: drift-check every alias that is present.
240    let present_aliases: Vec<String> = {
241        let schemas = get_mapping_path(doc, &schemas_path)
242            .ok_or_else(|| anyhow!("components.schemas not a mapping"))?;
243        aliases
244            .iter()
245            .copied()
246            .filter(|a| schemas.contains_key(Value::from(*a)))
247            .map(str::to_owned)
248            .collect()
249    };
250
251    for alias in &present_aliases {
252        let schema = get_mapping_path(doc, &schemas_path)
253            .and_then(|m| m.get(Value::from(alias.as_str())))
254            .expect("alias presence confirmed above");
255        let alias_shape = structural_shape(schema);
256        if alias_shape != canonical_shape {
257            bail!(
258                "alias `{alias}` has drifted from canonical `Error` shape — \
259                 either restore structural equality or remove `{alias}` from ERROR_ALIASES.\n\
260                 canonical: {:#?}\nalias:     {:#?}",
261                canonical_shape,
262                alias_shape
263            );
264        }
265    }
266
267    // Pass 2: rewrite every $ref pointing at an alias.
268    for alias in &present_aliases {
269        let from = format!("#/components/schemas/{alias}");
270        let to = "#/components/schemas/Error";
271        report.refs_rewritten += rewrite_refs(doc, &from, to);
272    }
273
274    // Pass 3: drop alias definitions from components.schemas.
275    if let Some(schemas) = get_mapping_path_mut(doc, &schemas_path) {
276        for alias in &present_aliases {
277            schemas.remove(Value::from(alias.as_str()));
278        }
279    }
280    report.folded_aliases = present_aliases;
281
282    // Pass 4: strip multipart-only operations (progenitor 0.9 limitation).
283    report.stripped_operations = strip_multipart_only_operations(doc);
284
285    // Pass 4b: collapse request bodies with >1 media type to a single
286    // preferred type. Must run after the multipart strip so multipart ops are
287    // already removed rather than collapsed onto their JSON variant.
288    report.request_media_collapsed = collapse_multi_media_request_bodies(doc);
289
290    // Pass 5: strip non-2xx responses from every surviving operation
291    // (progenitor 0.9 extract_responses assertion, error side).
292    report.error_responses_stripped = strip_non_success_responses(doc);
293
294    // Pass 6: strip OpenAPI 3.1 `{type: "null"}` variants from oneOf/anyOf
295    // arrays. Progenitor 0.9 panics on this nullable pattern; the caller's
296    // hand-rolled JSON-RPC forwarder uses serde_json::Value and doesn't
297    // need a generated type for the null branch.
298    report.null_variants_stripped = strip_null_type_variants(doc);
299
300    // Pass 7: open closed objects (gh#172). See [`strip_closed_objects`].
301    strip_closed_objects(doc);
302
303    // Pass 8: open response string enums (gh#176). See [`strip_response_enums`].
304    report.response_enums_stripped = strip_response_enums(doc);
305
306    // Pass 9: relax response `required` lists (gh#178). See [`relax_response_required`].
307    report.response_required_relaxed = relax_response_required(doc);
308
309    Ok(report)
310}
311
312/// Remove string `enum` keywords from schemas reachable from 2xx or `default`
313/// response bodies, following local `$ref`s transitively. Returns the count.
314///
315/// typify emits closed Rust enums that reject server-added values (gh#176).
316/// Opening response enums makes them `String`; request-body and parameter
317/// enums stay typed unless shared with a response (e.g. CompletionCriterionInput),
318/// in which case response tolerance wins. Non-string enums are left alone.
319/// Runs after the non-2xx and null-type-variant strips: error-only schemas are
320/// out of scope, and removed null branches must not make schemas reachable.
321/// `nullable: true` is preserved even when the enum contains `null`, so typify
322/// still emits `Option<String>` for nullable response values.
323fn strip_response_enums(doc: &mut Value) -> usize {
324    let enums: Vec<_> = response_schema_paths(doc)
325        .into_iter()
326        .filter(|path| {
327            value_at_path(doc, path).is_some_and(|schema| {
328                schema.get("type").and_then(Value::as_str) == Some("string")
329                    && schema.get("enum").is_some()
330            })
331        })
332        .collect();
333    let mut stripped = 0;
334    for path in enums {
335        if value_at_path_mut(doc, &path)
336            .and_then(Value::as_mapping_mut)
337            .and_then(|map| map.remove("enum"))
338            .is_some()
339        {
340            stripped += 1;
341        }
342    }
343    stripped
344}
345
346/// Cut every `required` list in a response-reachable schema down to the
347/// properties named in [`KEEP_REQUIRED`]. Returns how many entries were dropped.
348///
349/// typify emits a required property as a plain field and an optional one as
350/// `Option`, so a required property the server stops sending fails decode in
351/// every released CLI even when no CLI code reads it (gh#178). Kept properties
352/// still fail fast at the HTTP boundary, which is what the CLI wants for values
353/// it uses. Request-only schemas are untouched; a schema shared with a request
354/// is relaxed too (the CLI only serializes request bodies, and an optional
355/// field it always sets serializes the same). Optional arrays become
356/// `#[serde(default)] Vec<T>`, not `Option`.
357fn relax_response_required(doc: &mut Value) -> usize {
358    let mut relaxed = 0;
359    for path in response_schema_paths(doc) {
360        let key = schema_key(doc, &path);
361        let Some(map) = value_at_path_mut(doc, &path).and_then(Value::as_mapping_mut) else {
362            continue;
363        };
364        let Some(Value::Sequence(required)) = map.get_mut("required") else {
365            continue;
366        };
367        let before = required.len();
368        required.retain(|property| {
369            property
370                .as_str()
371                .is_some_and(|property| KEEP_REQUIRED.contains(&(key.as_str(), property)))
372        });
373        relaxed += before - required.len();
374        if required.is_empty() {
375            map.remove("required");
376        }
377    }
378    relaxed
379}
380
381/// The [`KEEP_REQUIRED`] key for a schema path: where the schema is declared,
382/// with `properties` segments dropped. `components.schemas.X.properties.p` →
383/// `X.p`; an `allOf` member → `X.allOf.1`; an inline response schema →
384/// `<operationId>.<status>`; a reusable response → `responses.<name>`.
385fn schema_key(doc: &Value, path: &[String]) -> String {
386    let (head, rest) = match path {
387        [c, s, name, rest @ ..] if c == "components" && s == "schemas" => (name.clone(), rest),
388        [c, r, name, _content, _media, _schema, rest @ ..]
389            if c == "components" && r == "responses" =>
390        {
391            (format!("responses.{name}"), rest)
392        }
393        [p, route, verb, _responses, code, _content, _media, _schema, rest @ ..]
394            if p == "paths" =>
395        {
396            let op = value_at_path(doc, &path[..3])
397                .and_then(|op| op.get("operationId"))
398                .and_then(Value::as_str)
399                .map_or_else(|| format!("{verb} {route}"), str::to_owned);
400            (format!("{op}.{code}"), rest)
401        }
402        _ => (path.join("."), &[][..]),
403    };
404    std::iter::once(head)
405        .chain(rest.iter().filter(|seg| *seg != "properties").cloned())
406        .collect::<Vec<_>>()
407        .join(".")
408}
409
410/// Every schema path reachable from a 2xx or `default` response body,
411/// following local `$ref`s transitively through composition, properties,
412/// arrays and map values.
413fn response_schema_paths(doc: &Value) -> BTreeSet<Vec<String>> {
414    let mut visited = BTreeSet::new();
415    let mut schemas = BTreeSet::new();
416    if let Some(paths) = doc.get("paths").and_then(Value::as_mapping) {
417        for (path, item) in paths {
418            let Some(path) = path.as_str() else { continue };
419            for verb in [
420                "get", "put", "post", "delete", "patch", "options", "head", "trace",
421            ] {
422                let Some(responses) = item
423                    .get(verb)
424                    .and_then(|op| op.get("responses"))
425                    .and_then(Value::as_mapping)
426                else {
427                    continue;
428                };
429                for code in responses
430                    .keys()
431                    .filter_map(Value::as_str)
432                    .filter(|code| is_success_or_default(code))
433                {
434                    let root = ["paths", path, verb, "responses", code]
435                        .map(str::to_owned)
436                        .to_vec();
437                    collect_response_schemas(doc, &root, &mut visited, &mut schemas);
438                }
439            }
440        }
441    }
442    schemas
443}
444
445fn value_at_path<'a>(doc: &'a Value, path: &[String]) -> Option<&'a Value> {
446    path.iter().try_fold(doc, |node, key| match node {
447        Value::Sequence(seq) => seq.get(key.parse::<usize>().ok()?),
448        _ => node.get(key.as_str()),
449    })
450}
451
452fn value_at_path_mut<'a>(doc: &'a mut Value, path: &[String]) -> Option<&'a mut Value> {
453    path.iter().try_fold(doc, |node, key| match node {
454        Value::Sequence(seq) => seq.get_mut(key.parse::<usize>().ok()?),
455        _ => node.get_mut(key.as_str()),
456    })
457}
458
459fn local_ref_path(node: &Value) -> Option<Vec<String>> {
460    Some(
461        node.get("$ref")?
462            .as_str()?
463            .strip_prefix("#/")?
464            .split('/')
465            .map(|key| key.replace("~1", "/").replace("~0", "~"))
466            .collect(),
467    )
468}
469
470fn collect_response_schemas(
471    doc: &Value,
472    path: &[String],
473    visited: &mut BTreeSet<Vec<String>>,
474    schemas: &mut BTreeSet<Vec<String>>,
475) {
476    if !visited.insert(path.to_vec()) {
477        return;
478    }
479    let Some(response) = value_at_path(doc, path) else {
480        return;
481    };
482    if let Some(target) = local_ref_path(response) {
483        collect_response_schemas(doc, &target, visited, schemas);
484    }
485    if let Some(content) = response.get("content").and_then(Value::as_mapping) {
486        for media in content.keys().filter_map(Value::as_str) {
487            let mut schema_path = path.to_vec();
488            schema_path.extend(["content".to_owned(), media.to_owned(), "schema".to_owned()]);
489            collect_schemas(doc, &schema_path, visited, schemas);
490        }
491    }
492}
493
494fn collect_schemas(
495    doc: &Value,
496    path: &[String],
497    visited: &mut BTreeSet<Vec<String>>,
498    schemas: &mut BTreeSet<Vec<String>>,
499) {
500    if !visited.insert(path.to_vec()) {
501        return;
502    }
503    let Some(schema) = value_at_path(doc, path) else {
504        return;
505    };
506    schemas.insert(path.to_vec());
507    if let Some(target) = local_ref_path(schema) {
508        collect_schemas(doc, &target, visited, schemas);
509    }
510    for key in [
511        "allOf",
512        "oneOf",
513        "anyOf",
514        "properties",
515        "additionalProperties",
516        "items",
517    ] {
518        let Some(child) = schema.get(key) else {
519            continue;
520        };
521        let mut child_path = path.to_vec();
522        child_path.push(key.to_owned());
523        match child {
524            Value::Sequence(seq) => {
525                for index in 0..seq.len() {
526                    let mut item_path = child_path.clone();
527                    item_path.push(index.to_string());
528                    collect_schemas(doc, &item_path, visited, schemas);
529                }
530            }
531            Value::Mapping(map) if key == "properties" => {
532                for property in map.keys().filter_map(Value::as_str) {
533                    let mut property_path = child_path.clone();
534                    property_path.push(property.to_owned());
535                    collect_schemas(doc, &property_path, visited, schemas);
536                }
537            }
538            _ => collect_schemas(doc, &child_path, visited, schemas),
539        }
540    }
541}
542
543/// Recursively remove every `additionalProperties: false`.
544///
545/// typify turns that keyword into `#[serde(deny_unknown_fields)]`, so a single
546/// field the server adds to a response would fail decode in every released
547/// CLI. Stripping it everywhere (not just responses) is safe: the attribute
548/// only affects deserialization, and the CLI only ever serializes request
549/// bodies. Non-`false` values (`true`, a schema) are left alone — they change
550/// the generated type shape, not just strictness.
551fn strip_closed_objects(v: &mut Value) {
552    match v {
553        Value::Mapping(map) => {
554            if map.get("additionalProperties") == Some(&Value::Bool(false)) {
555                map.remove("additionalProperties");
556            }
557            for (_, child) in map.iter_mut() {
558                strip_closed_objects(child);
559            }
560        }
561        Value::Sequence(seq) => seq.iter_mut().for_each(strip_closed_objects),
562        _ => {}
563    }
564}
565
566/// Recursively walk `doc` and drop any `{type: "null"}` entries from `oneOf`
567/// and `anyOf` arrays. Returns the total count stripped.
568///
569/// A schema is considered a null-type variant when its `type` field equals
570/// the string `"null"` — sibling keys like `description` are tolerated so
571/// codegen tools that re-emit the spec with extra annotations still match.
572///
573/// If stripping leaves an empty `oneOf`/`anyOf`, the keyword is removed
574/// outright: an empty schema array is invalid OpenAPI and would trip the
575/// downstream generator harder than the original null-variant did.
576fn strip_null_type_variants(doc: &mut Value) -> usize {
577    fn is_null_type_schema(v: &Value) -> bool {
578        v.as_mapping()
579            .and_then(|m| m.get("type"))
580            .and_then(Value::as_str)
581            == Some("null")
582    }
583    fn walk(v: &mut Value, count: &mut usize) {
584        match v {
585            Value::Mapping(map) => {
586                for key in NULL_VARIANT_SCHEMA_KEYS {
587                    let Some(seq) = map.get_mut(*key).and_then(Value::as_sequence_mut) else {
588                        continue;
589                    };
590                    let before = seq.len();
591                    seq.retain(|item| !is_null_type_schema(item));
592                    *count += before - seq.len();
593                    if before > 0 && seq.is_empty() {
594                        map.remove(*key);
595                    }
596                }
597                for (_, child) in map.iter_mut() {
598                    walk(child, count);
599                }
600            }
601            Value::Sequence(seq) => {
602                for child in seq.iter_mut() {
603                    walk(child, count);
604                }
605            }
606            _ => {}
607        }
608    }
609    let mut count = 0;
610    walk(doc, &mut count);
611    count
612}
613
614/// Remove operations whose request body has exactly one content variant,
615/// `multipart/form-data`. Returns the removed `operationId`s (stable order).
616///
617/// We don't touch operations that declare multipart *alongside* another
618/// variant — those are handled by a preferred-content-type collapse pass that
619/// doesn't exist yet; today no such operations exist in the TaskFast spec.
620fn strip_multipart_only_operations(doc: &mut Value) -> Vec<String> {
621    let mut stripped = Vec::new();
622    let Some(paths) = doc
623        .as_mapping_mut()
624        .and_then(|m| m.get_mut(Value::from("paths")))
625        .and_then(Value::as_mapping_mut)
626    else {
627        return stripped;
628    };
629
630    const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
631    for (_path_key, path_item) in paths.iter_mut() {
632        let Some(ops) = path_item.as_mapping_mut() else {
633            continue;
634        };
635        let mut to_remove = Vec::new();
636        for verb in HTTP_VERBS {
637            if let Some(op) = ops.get(Value::from(*verb)) {
638                if operation_is_multipart_only(op) {
639                    let op_id = op
640                        .get(Value::from("operationId"))
641                        .and_then(Value::as_str)
642                        .unwrap_or("<anonymous>")
643                        .to_string();
644                    to_remove.push(*verb);
645                    stripped.push(op_id);
646                }
647            }
648        }
649        for verb in to_remove {
650            ops.remove(Value::from(verb));
651        }
652    }
653    stripped
654}
655
656/// Collapse any `requestBody.content` mapping with more than one media type
657/// down to a single preferred entry: `application/json` if present, otherwise
658/// the first declared media type (insertion order preserved). Returns the
659/// number of request bodies collapsed.
660///
661/// progenitor 0.x cannot pick among multiple request media types and panics
662/// with `more media types than expected`. Today the only affected operations
663/// are the OAuth endpoints, whose JSON and form-urlencoded variants reference
664/// the same schema, so the collapse is lossless. Multipart-bearing operations
665/// must already be stripped before this runs (see [`strip_multipart_only_operations`]).
666fn collapse_multi_media_request_bodies(doc: &mut Value) -> usize {
667    let mut collapsed = 0usize;
668    let Some(paths) = doc
669        .as_mapping_mut()
670        .and_then(|m| m.get_mut(Value::from("paths")))
671        .and_then(Value::as_mapping_mut)
672    else {
673        return 0;
674    };
675
676    const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
677    for (_path_key, path_item) in paths.iter_mut() {
678        let Some(ops) = path_item.as_mapping_mut() else {
679            continue;
680        };
681        for verb in HTTP_VERBS {
682            let Some(content) = ops
683                .get_mut(Value::from(*verb))
684                .and_then(Value::as_mapping_mut)
685                .and_then(|op| op.get_mut(Value::from("requestBody")))
686                .and_then(Value::as_mapping_mut)
687                .and_then(|body| body.get_mut(Value::from("content")))
688                .and_then(Value::as_mapping_mut)
689            else {
690                continue;
691            };
692            if content.len() <= 1 {
693                continue;
694            }
695            let keep = if content.contains_key(Value::from("application/json")) {
696                "application/json".to_string()
697            } else {
698                content
699                    .keys()
700                    .next()
701                    .and_then(Value::as_str)
702                    .map(str::to_owned)
703                    .expect("content has >1 entry")
704            };
705            content.retain(|k, _| k.as_str() == Some(keep.as_str()));
706            collapsed += 1;
707        }
708    }
709    collapsed
710}
711
712/// For each operation under `paths.*.<verb>.responses`, remove every entry
713/// whose key is not a 2xx status code or the literal `default`. Returns the
714/// total count of removed entries across the document.
715///
716/// Rationale: progenitor 0.9 asserts that both the success and error response
717/// sets have at most one distinct body type. TaskFast endpoints declare
718/// multiple distinct error shapes (`Error`, `ValidationError`, feature-specific
719/// failure envelopes). Surfacing those as typed variants in the generated
720/// client is not the generator's job here — `taskfast-client::errors::Error`
721/// reconstructs error semantics from the raw response body. Dropping non-2xx
722/// response definitions short-circuits the assertion without hiding anything
723/// the client layer doesn't already re-implement.
724fn strip_non_success_responses(doc: &mut Value) -> usize {
725    let mut removed = 0usize;
726    let Some(paths) = doc
727        .as_mapping_mut()
728        .and_then(|m| m.get_mut(Value::from("paths")))
729        .and_then(Value::as_mapping_mut)
730    else {
731        return 0;
732    };
733
734    const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
735    for (_path_key, path_item) in paths.iter_mut() {
736        let Some(ops) = path_item.as_mapping_mut() else {
737            continue;
738        };
739        for verb in HTTP_VERBS {
740            let Some(op) = ops.get_mut(Value::from(*verb)) else {
741                continue;
742            };
743            let Some(responses) = op
744                .as_mapping_mut()
745                .and_then(|m| m.get_mut(Value::from("responses")))
746                .and_then(Value::as_mapping_mut)
747            else {
748                continue;
749            };
750
751            let to_remove: Vec<Value> = responses
752                .iter()
753                .filter_map(|(k, _)| {
754                    let key = k.as_str()?;
755                    if is_success_or_default(key) {
756                        None
757                    } else {
758                        Some(k.clone())
759                    }
760                })
761                .collect();
762            for k in to_remove {
763                if responses.remove(&k).is_some() {
764                    removed += 1;
765                }
766            }
767        }
768    }
769    removed
770}
771
772fn is_success_or_default(code: &str) -> bool {
773    if code == "default" {
774        return true;
775    }
776    // 2XX ranges (e.g. "2XX") and explicit codes 200..=299.
777    match code.chars().next() {
778        Some('2') => true,
779        _ => false,
780    }
781}
782
783fn operation_is_multipart_only(op: &Value) -> bool {
784    let Some(body) = op
785        .as_mapping()
786        .and_then(|m| m.get(Value::from("requestBody")))
787    else {
788        return false;
789    };
790    let Some(content) = body
791        .as_mapping()
792        .and_then(|m| m.get(Value::from("content")))
793        .and_then(Value::as_mapping)
794    else {
795        return false;
796    };
797    let keys: Vec<&str> = content.keys().filter_map(Value::as_str).collect();
798    // Match either (a) sole multipart variant or (b) any operation that
799    // declares multipart alongside other variants — progenitor 0.9 trips on
800    // multi-variant bodies too (`more media types than expected`), and the
801    // upload path is hand-rolled in taskfast-client regardless.
802    keys.contains(&"multipart/form-data")
803}
804
805/// Structural projection: strip doc-only fields so two schemas differing only
806/// in `description`/`example` compare equal.
807fn structural_shape(v: &Value) -> Value {
808    match v {
809        Value::Mapping(m) => {
810            let mut out = Mapping::new();
811            for (k, val) in m {
812                let keep = match k.as_str() {
813                    Some("description") | Some("example") | Some("summary") | Some("default")
814                    | Some("deprecated") | Some("title") => false,
815                    _ => true,
816                };
817                if keep {
818                    out.insert(k.clone(), structural_shape(val));
819                }
820            }
821            Value::Mapping(out)
822        }
823        Value::Sequence(s) => Value::Sequence(s.iter().map(structural_shape).collect()),
824        other => other.clone(),
825    }
826}
827
828/// Walk `doc`, replacing every `$ref` whose value equals `from` with `to`.
829/// Returns the number of rewrites performed.
830fn rewrite_refs(doc: &mut Value, from: &str, to: &str) -> usize {
831    let mut count = 0;
832    rewrite_refs_inner(doc, from, to, &mut count);
833    count
834}
835
836fn rewrite_refs_inner(v: &mut Value, from: &str, to: &str, count: &mut usize) {
837    match v {
838        Value::Mapping(m) => {
839            // A $ref object is typically `{"$ref": "..."}` — a single-key mapping.
840            // We still walk siblings because spec-extension keys may coexist.
841            if let Some(target) = m.get_mut(Value::from("$ref")) {
842                if target.as_str() == Some(from) {
843                    *target = Value::from(to);
844                    *count += 1;
845                }
846            }
847            for (_, val) in m.iter_mut() {
848                rewrite_refs_inner(val, from, to, count);
849            }
850        }
851        Value::Sequence(s) => {
852            for val in s.iter_mut() {
853                rewrite_refs_inner(val, from, to, count);
854            }
855        }
856        _ => {}
857    }
858}
859
860fn get_mapping_path<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Mapping> {
861    let mut cur = v;
862    for key in path {
863        cur = cur.as_mapping()?.get(Value::from(*key))?;
864    }
865    cur.as_mapping()
866}
867
868fn get_mapping_path_mut<'a>(v: &'a mut Value, path: &[&str]) -> Option<&'a mut Mapping> {
869    let mut cur = v;
870    for key in path {
871        cur = cur.as_mapping_mut()?.get_mut(Value::from(*key))?;
872    }
873    cur.as_mapping_mut()
874}
875
876fn tracing_nothing(_r: &Report) {}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881
882    const BASE_SPEC: &str = r#"
883openapi: 3.0.0
884info:
885  title: test
886  version: 0.0.0
887paths:
888  /foo:
889    get:
890      responses:
891        '200':
892          description: ok
893        '404':
894          description: not found
895          content:
896            application/json:
897              schema:
898                $ref: '#/components/schemas/WalletBalanceNotFoundError'
899        '503':
900          description: unavailable
901          content:
902            application/json:
903              schema:
904                $ref: '#/components/schemas/Error'
905components:
906  schemas:
907    Error:
908      type: object
909      required:
910        - error
911        - message
912      properties:
913        error:
914          type: string
915          description: Error code
916        message:
917          type: string
918          description: Human message
919    WalletBalanceNotFoundError:
920      type: object
921      required:
922        - error
923        - message
924      properties:
925        error:
926          type: string
927          description: Error code
928          example: "wallet_not_applicable"
929        message:
930          type: string
931          description: Human message
932          example: "not a tempo agent"
933    Untouched:
934      type: object
935"#;
936
937    #[test]
938    fn normalize_folds_alias_refs_and_drops_schema() {
939        let (out, report) =
940            normalize_with_aliases(BASE_SPEC, &["WalletBalanceNotFoundError"]).unwrap();
941        assert_eq!(
942            report.folded_aliases,
943            vec!["WalletBalanceNotFoundError".to_string()]
944        );
945        assert_eq!(report.refs_rewritten, 1);
946
947        // The canonical Error schema definition still lives in components
948        // (non-2xx response strip removes the *usages*, not the shared type).
949        assert!(
950            out.contains("Error:\n      type: object"),
951            "canonical Error schema missing:\n{out}"
952        );
953        assert!(
954            !out.contains("WalletBalanceNotFoundError"),
955            "alias name fully removed, got:\n{out}"
956        );
957
958        // Siblings untouched.
959        assert!(out.contains("Untouched:"));
960    }
961
962    #[test]
963    fn idempotent_when_no_aliases_present() {
964        let (out1, _) = normalize_spec_with_report(BASE_SPEC).unwrap();
965        let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
966        assert_eq!(out1, out2);
967        assert!(report2.folded_aliases.is_empty());
968        assert_eq!(report2.refs_rewritten, 0);
969    }
970
971    #[test]
972    fn drift_check_rejects_mismatched_alias() {
973        // Same as BASE_SPEC but WalletBalanceNotFoundError gets an extra required field.
974        let drifted = BASE_SPEC.replace(
975            "WalletBalanceNotFoundError:\n      type: object\n      required:\n        - error\n        - message",
976            "WalletBalanceNotFoundError:\n      type: object\n      required:\n        - error\n        - message\n        - drift_field",
977        );
978        let err = normalize_with_aliases(&drifted, &["WalletBalanceNotFoundError"]).unwrap_err();
979        let msg = format!("{err:#}");
980        assert!(msg.contains("drifted from canonical"), "got: {msg}");
981    }
982
983    #[test]
984    fn missing_error_schema_is_error() {
985        let bad = BASE_SPEC.replace("Error:", "NotError:");
986        let err = normalize_spec(bad.as_str()).unwrap_err();
987        assert!(format!("{err:#}").contains("canonical schema `Error` not found"));
988    }
989
990    const MULTIPART_SPEC: &str = r#"
991openapi: 3.0.0
992info: { title: test, version: 0.0.0 }
993paths:
994  /upload:
995    post:
996      operationId: uploadThing
997      requestBody:
998        required: true
999        content:
1000          multipart/form-data:
1001            schema:
1002              type: object
1003              required: [file]
1004              properties:
1005                file: { type: string, format: binary }
1006      responses:
1007        '201': { description: ok }
1008    get:
1009      operationId: listThings
1010      responses:
1011        '200': { description: ok }
1012  /json_only:
1013    post:
1014      operationId: postJsonThing
1015      requestBody:
1016        required: true
1017        content:
1018          application/json:
1019            schema: { type: object }
1020      responses:
1021        '201': { description: ok }
1022components:
1023  schemas:
1024    Error:
1025      type: object
1026      required: [error, message]
1027      properties:
1028        error: { type: string }
1029        message: { type: string }
1030"#;
1031
1032    #[test]
1033    fn non_success_responses_are_stripped() {
1034        let (out, report) = normalize_spec_with_report(BASE_SPEC).unwrap();
1035        // BASE_SPEC has one op with 200/404/503 — 404 and 503 must go.
1036        assert_eq!(report.error_responses_stripped, 2);
1037        // The success code survives.
1038        assert!(out.contains("'200':"));
1039        // Error codes are gone.
1040        assert!(!out.contains("'404':"), "404 not stripped:\n{out}");
1041        assert!(!out.contains("'503':"), "503 not stripped:\n{out}");
1042    }
1043
1044    #[test]
1045    fn multi_media_request_body_collapses_to_json() {
1046        const SPEC: &str = r#"
1047openapi: 3.0.0
1048info: { title: test, version: 0.0.0 }
1049paths:
1050  /oauth2/token:
1051    post:
1052      operationId: oauthToken
1053      requestBody:
1054        required: true
1055        content:
1056          application/json:
1057            schema: { $ref: '#/components/schemas/Tok' }
1058          application/x-www-form-urlencoded:
1059            schema: { $ref: '#/components/schemas/Tok' }
1060      responses:
1061        '200': { description: ok }
1062  /forms:
1063    post:
1064      operationId: formOnly
1065      requestBody:
1066        content:
1067          application/x-www-form-urlencoded:
1068            schema: { type: object }
1069          text/plain:
1070            schema: { type: string }
1071      responses:
1072        '200': { description: ok }
1073components:
1074  schemas:
1075    Error:
1076      type: object
1077      required: [error, message]
1078      properties:
1079        error: { type: string }
1080        message: { type: string }
1081    Tok:
1082      type: object
1083"#;
1084        let (out, report) = normalize_spec_with_report(SPEC).unwrap();
1085        // Both multi-media request bodies collapse to a single type.
1086        assert_eq!(report.request_media_collapsed, 2);
1087        // oauthToken: JSON preferred — exactly one json media type remains
1088        // (its form variant is gone).
1089        assert_eq!(
1090            out.matches("application/json:").count(),
1091            1,
1092            "expected oauth body kept as json:\n{out}"
1093        );
1094        // formOnly: no JSON, so the first declared type (form) wins and the
1095        // text/plain variant is dropped.
1096        assert_eq!(
1097            out.matches("application/x-www-form-urlencoded:").count(),
1098            1,
1099            "expected form-only body kept as form-urlencoded:\n{out}"
1100        );
1101        assert!(
1102            !out.contains("text/plain"),
1103            "lower-priority text/plain variant not collapsed:\n{out}"
1104        );
1105    }
1106
1107    #[test]
1108    fn multipart_only_operation_is_stripped() {
1109        let (out, report) = normalize_spec_with_report(MULTIPART_SPEC).unwrap();
1110        assert_eq!(report.stripped_operations, vec!["uploadThing".to_string()]);
1111        assert!(!out.contains("uploadThing"), "uploadThing not stripped");
1112        // Siblings untouched.
1113        assert!(out.contains("listThings"), "sibling GET removed by mistake");
1114        assert!(
1115            out.contains("postJsonThing"),
1116            "JSON-only POST removed by mistake"
1117        );
1118        // The path entry itself survives because listThings still lives there.
1119        assert!(out.contains("/upload:"));
1120    }
1121
1122    const NULL_VARIANT_SPEC: &str = r#"
1123openapi: 3.0.0
1124info: { title: test, version: 0.0.0 }
1125paths:
1126  /thing:
1127    get:
1128      responses:
1129        '200':
1130          description: ok
1131          content:
1132            application/json:
1133              schema:
1134                oneOf:
1135                  - { type: object, properties: { name: { type: string } } }
1136                  - { type: "null" }
1137components:
1138  schemas:
1139    Error:
1140      type: object
1141      required: [error, message]
1142      properties:
1143        error: { type: string }
1144        message: { type: string }
1145    Nullable:
1146      anyOf:
1147        - { type: integer }
1148        - { type: "null", description: "explicit absence" }
1149    AllNull:
1150      oneOf:
1151        - { type: "null" }
1152        - { type: "null" }
1153    Untouched:
1154      oneOf:
1155        - { type: integer }
1156        - { type: string }
1157"#;
1158
1159    #[test]
1160    fn null_type_variants_are_stripped_and_counted() {
1161        let (out, report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
1162        // 1 in /thing oneOf + 1 in Nullable.anyOf + 2 in AllNull.oneOf = 4.
1163        assert_eq!(report.null_variants_stripped, 4);
1164
1165        // Surviving variants kept.
1166        assert!(out.contains("Nullable:"));
1167        assert!(out.contains("Untouched:"));
1168        // Sibling keys (description) on a null-type variant don't shield it.
1169        assert!(
1170            !out.contains("explicit absence"),
1171            "null variant with sibling description not stripped:\n{out}"
1172        );
1173        // Untouched non-null oneOf preserved verbatim.
1174        assert!(
1175            out.contains("Untouched:") && out.matches("type: string").count() >= 2,
1176            "Untouched.oneOf altered:\n{out}"
1177        );
1178    }
1179
1180    #[test]
1181    fn empty_oneof_after_strip_is_removed_outright() {
1182        let (out, _report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
1183        // AllNull had only null variants; the oneOf key must be gone, leaving
1184        // the (now-empty) schema definition without an invalid `oneOf: []`.
1185        // Find the AllNull block and assert no oneOf inside it.
1186        let idx = out.find("AllNull:").expect("AllNull schema present");
1187        let tail = &out[idx..];
1188        // Stop at the next sibling schema or end-of-doc.
1189        let block_end = tail[8..].find("\n    ").map_or(tail.len(), |n| n + 8);
1190        let block = &tail[..block_end];
1191        assert!(
1192            !block.contains("oneOf"),
1193            "empty oneOf not removed from AllNull:\n{block}"
1194        );
1195    }
1196
1197    #[test]
1198    fn null_strip_is_idempotent() {
1199        let (out1, _) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
1200        let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
1201        assert_eq!(out1, out2);
1202        assert_eq!(report2.null_variants_stripped, 0);
1203    }
1204
1205    #[test]
1206    fn response_enums_are_opened_but_request_enums_stay_typed() {
1207        const SPEC: &str = r#"
1208openapi: 3.0.0
1209info: { title: test, version: 0.0.0 }
1210paths:
1211  /thing:
1212    post:
1213      parameters:
1214        - { name: filter, in: query, schema: { type: string, enum: [known] } }
1215      requestBody:
1216        content:
1217          application/json:
1218            schema: { $ref: '#/components/schemas/Request' }
1219      responses:
1220        '200':
1221          description: ok
1222          content:
1223            application/json:
1224              schema: { $ref: '#/components/schemas/Response' }
1225        default:
1226          description: fallback
1227          content:
1228            application/json:
1229              schema: { type: string, enum: [fallback] }
1230        '400':
1231          description: bad request
1232          content:
1233            application/json:
1234              schema: { $ref: '#/components/schemas/Error' }
1235components:
1236  schemas:
1237    Error: { type: string, enum: [bad] }
1238    Request:
1239      type: object
1240      properties:
1241        kind: { type: string, enum: [known] }
1242        shared: { $ref: '#/components/schemas/Shared' }
1243    Shared: { type: string, enum: [known] }
1244    Response:
1245      allOf:
1246        - type: object
1247          properties:
1248            status: { type: string, enum: [active] }
1249            nullable_status: { type: string, nullable: true, enum: [active, null] }
1250            shared: { $ref: '#/components/schemas/Shared' }
1251            recursive: { $ref: '#/components/schemas/Response' }
1252            choices:
1253              oneOf:
1254                - { $ref: '#/components/schemas/Choice' }
1255                - { type: "null" }
1256            alternatives:
1257              anyOf:
1258                - { type: string, enum: [known] }
1259            list:
1260              type: array
1261              items: { $ref: '#/components/schemas/Choice' }
1262            map:
1263              type: object
1264              additionalProperties: { $ref: '#/components/schemas/Choice' }
1265            number: { type: integer, enum: [1, 2] }
1266    Choice: { type: string, enum: [known] }
1267"#;
1268        let (out, _) = normalize_spec_with_report(SPEC).unwrap();
1269        let doc: Value = serde_yaml::from_str(&out).unwrap();
1270        let schemas = &doc["components"]["schemas"];
1271        let properties = &schemas["Response"]["allOf"][0]["properties"];
1272        assert!(properties["status"].get("enum").is_none());
1273        assert!(properties["nullable_status"].get("enum").is_none());
1274        assert_eq!(properties["nullable_status"]["nullable"], Value::Bool(true));
1275        assert!(schemas["Request"]["properties"]["kind"]
1276            .get("enum")
1277            .is_some());
1278        assert!(doc["paths"]["/thing"]["post"]["parameters"][0]["schema"]
1279            .get("enum")
1280            .is_some());
1281        assert!(schemas["Shared"].get("enum").is_none());
1282        assert!(schemas["Choice"].get("enum").is_none());
1283        assert!(properties["alternatives"]["anyOf"][0].get("enum").is_none());
1284        assert!(properties["number"].get("enum").is_some());
1285        assert!(schemas["Error"].get("enum").is_some());
1286        assert!(
1287            doc["paths"]["/thing"]["post"]["responses"]["default"]["content"]["application/json"]
1288                ["schema"]
1289                .get("enum")
1290                .is_none()
1291        );
1292    }
1293
1294    #[test]
1295    fn reusable_responses_follow_refs_without_opening_example_enums() {
1296        const SPEC: &str = r#"
1297openapi: 3.0.0
1298info: { title: test, version: 0.0.0 }
1299paths:
1300  /thing:
1301    get:
1302      responses:
1303        '2XX': { $ref: '#/components/responses/Thing' }
1304components:
1305  responses:
1306    Thing:
1307      description: ok
1308      content:
1309        application/json:
1310          schema: { $ref: '#/components/schemas/Thing~1Status~0' }
1311          example: { type: string, enum: [literal_payload] }
1312  schemas:
1313    Error: { type: object }
1314    Thing/Status~: { type: string, enum: [active] }
1315"#;
1316        let (out, report) = normalize_spec_with_report(SPEC).unwrap();
1317        let doc: Value = serde_yaml::from_str(&out).unwrap();
1318        assert!(doc["components"]["schemas"]["Thing/Status~"]
1319            .get("enum")
1320            .is_none());
1321        assert!(
1322            doc["components"]["responses"]["Thing"]["content"]["application/json"]["example"]
1323                .get("enum")
1324                .is_some()
1325        );
1326        assert_eq!(report.response_enums_stripped, 1);
1327    }
1328
1329    #[test]
1330    fn live_spec_response_enum_inventory_is_normalized() {
1331        // Independent inventory of the vendored spec, including transitive refs.
1332        // Counts are measured here, not used as limits by the production pass.
1333        fn enum_count(node: &Value) -> usize {
1334            match node {
1335                Value::Mapping(map) => {
1336                    usize::from(
1337                        map.get("type").and_then(Value::as_str) == Some("string")
1338                            && map.contains_key("enum"),
1339                    ) + map.values().map(enum_count).sum::<usize>()
1340                }
1341                Value::Sequence(seq) => seq.iter().map(enum_count).sum(),
1342                _ => 0,
1343            }
1344        }
1345        fn refs(node: &Value, schemas: &Mapping, found: &mut BTreeSet<String>) {
1346            match node {
1347                Value::Mapping(map) => {
1348                    if let Some(name) = map
1349                        .get("$ref")
1350                        .and_then(Value::as_str)
1351                        .and_then(|target| target.strip_prefix("#/components/schemas/"))
1352                    {
1353                        if found.insert(name.to_owned()) {
1354                            refs(&schemas[name], schemas, found);
1355                        }
1356                    }
1357                    for child in map.values() {
1358                        refs(child, schemas, found);
1359                    }
1360                }
1361                Value::Sequence(seq) => {
1362                    for child in seq {
1363                        refs(child, schemas, found);
1364                    }
1365                }
1366                _ => {}
1367            }
1368        }
1369        let src = include_str!("../../../spec/openapi.yaml");
1370        let doc: Value = serde_yaml::from_str(src).unwrap();
1371        let schemas = doc["components"]["schemas"].as_mapping().unwrap();
1372        let mut response_refs = BTreeSet::new();
1373        let mut request_refs = BTreeSet::new();
1374        let mut inline_response_ops = 0;
1375        let mut inline_request_ops = 0;
1376        let mut inline_response_enums = 0;
1377        for item in doc["paths"].as_mapping().unwrap().values() {
1378            refs(&item["parameters"], schemas, &mut request_refs);
1379            for verb in [
1380                "get", "put", "post", "delete", "patch", "options", "head", "trace",
1381            ] {
1382                let Some(op) = item.get(verb) else { continue };
1383                refs(&op["requestBody"], schemas, &mut request_refs);
1384                refs(&op["parameters"], schemas, &mut request_refs);
1385                inline_request_ops +=
1386                    usize::from(enum_count(&op["requestBody"]) + enum_count(&op["parameters"]) > 0);
1387                let mut op_enums = 0;
1388                for (code, response) in op["responses"].as_mapping().unwrap() {
1389                    let code = code.as_str().unwrap();
1390                    if code == "default" || code.starts_with('2') {
1391                        refs(&response["content"], schemas, &mut response_refs);
1392                        op_enums += enum_count(&response["content"]);
1393                    }
1394                }
1395                inline_response_ops += usize::from(op_enums > 0);
1396                inline_response_enums += op_enums;
1397            }
1398        }
1399        let enum_components: BTreeSet<String> = schemas
1400            .iter()
1401            .filter(|(_, schema)| enum_count(schema) > 0)
1402            .map(|(name, _)| name.as_str().unwrap().to_owned())
1403            .collect();
1404        let response_components: BTreeSet<_> = enum_components
1405            .intersection(&response_refs)
1406            .cloned()
1407            .collect();
1408        let request_components: BTreeSet<_> = enum_components
1409            .intersection(&request_refs)
1410            .cloned()
1411            .collect();
1412        assert_eq!(
1413            response_components.difference(&request_components).count(),
1414            50
1415        );
1416        assert_eq!(
1417            request_components.difference(&response_components).count(),
1418            8
1419        );
1420        assert_eq!(
1421            response_components
1422                .intersection(&request_components)
1423                .map(String::as_str)
1424                .collect::<Vec<_>>(),
1425            ["CompletionCriterionInput"]
1426        );
1427        assert_eq!(
1428            enum_components
1429                .difference(
1430                    &response_components
1431                        .union(&request_components)
1432                        .cloned()
1433                        .collect()
1434                )
1435                .map(String::as_str)
1436                .collect::<Vec<_>>(),
1437            ["RateLimitError"]
1438        );
1439        assert_eq!(inline_response_ops, 3);
1440        assert_eq!(inline_request_ops, 10);
1441        let expected = inline_response_enums
1442            + response_components
1443                .iter()
1444                .map(|name| enum_count(&schemas[name.as_str()]))
1445                .sum::<usize>();
1446        let (out, report) = normalize_spec_with_report(src).unwrap();
1447        assert_eq!(report.response_enums_stripped, expected);
1448        let normalized: Value = serde_yaml::from_str(&out).unwrap();
1449        for name in &response_components {
1450            assert_eq!(
1451                enum_count(&normalized["components"]["schemas"][name.as_str()]),
1452                0,
1453                "response component {name}"
1454            );
1455        }
1456        for name in request_components.difference(&response_components) {
1457            assert_eq!(
1458                enum_count(&normalized["components"]["schemas"][name.as_str()]),
1459                enum_count(&schemas[name.as_str()]),
1460                "request-only component {name}"
1461            );
1462        }
1463        let (again, report) = normalize_spec_with_report(&out).unwrap();
1464        assert_eq!(again, out);
1465        assert_eq!(report.response_enums_stripped, 0);
1466    }
1467
1468    #[test]
1469    fn response_required_is_relaxed_except_kept_and_request_fields() {
1470        // `WebhookConfig.url` and `getStakeQuote.200.required_amount` are real
1471        // KEEP_REQUIRED entries (a component and an inline response schema).
1472        const SPEC: &str = r#"
1473openapi: 3.0.0
1474info: { title: test, version: 0.0.0 }
1475paths:
1476  /hooks:
1477    put:
1478      requestBody:
1479        content:
1480          application/json:
1481            schema: { $ref: '#/components/schemas/Request' }
1482      responses:
1483        '200':
1484          description: ok
1485          content:
1486            application/json:
1487              schema: { $ref: '#/components/schemas/WebhookConfig' }
1488  /stake/quote:
1489    get:
1490      operationId: getStakeQuote
1491      responses:
1492        '200':
1493          description: ok
1494          content:
1495            application/json:
1496              schema:
1497                type: object
1498                required: [required_amount, currency]
1499                properties:
1500                  required_amount: { type: integer }
1501                  currency: { type: string }
1502components:
1503  schemas:
1504    Error: { type: object }
1505    Request:
1506      type: object
1507      required: [url]
1508      properties:
1509        url: { type: string }
1510    WebhookConfig:
1511      type: object
1512      required: [url, secret, nested]
1513      properties:
1514        url: { type: string }
1515        secret: { type: string }
1516        nested:
1517          type: object
1518          required: [inner]
1519          properties:
1520            inner: { type: string }
1521"#;
1522        let (out, report) = normalize_spec_with_report(SPEC).unwrap();
1523        let doc: Value = serde_yaml::from_str(&out).unwrap();
1524        let schemas = &doc["components"]["schemas"];
1525        let required = |node: &Value| -> Vec<String> {
1526            node.get("required")
1527                .and_then(Value::as_sequence)
1528                .map(|seq| {
1529                    seq.iter()
1530                        .filter_map(Value::as_str)
1531                        .map(str::to_owned)
1532                        .collect()
1533                })
1534                .unwrap_or_default()
1535        };
1536        assert_eq!(required(&schemas["WebhookConfig"]), ["url"]);
1537        assert!(schemas["WebhookConfig"]["properties"]["nested"]
1538            .get("required")
1539            .is_none());
1540        assert_eq!(required(&schemas["Request"]), ["url"]);
1541        let quote = &doc["paths"]["/stake/quote"]["get"]["responses"]["200"]["content"]
1542            ["application/json"]["schema"];
1543        assert_eq!(required(quote), ["required_amount"]);
1544        // secret, nested, nested.inner, currency.
1545        assert_eq!(report.response_required_relaxed, 4);
1546        let (again, report) = normalize_spec_with_report(&out).unwrap();
1547        assert_eq!(again, out);
1548        assert_eq!(report.response_required_relaxed, 0);
1549    }
1550
1551    #[test]
1552    fn every_kept_required_entry_exists_in_the_vendored_spec() {
1553        // A renamed or removed server field must not leave a dead entry that
1554        // silently keeps nothing.
1555        let src = include_str!("../../../spec/openapi.yaml");
1556        let doc: Value = serde_yaml::from_str(src).unwrap();
1557        let mut present = BTreeSet::new();
1558        for path in response_schema_paths(&doc) {
1559            let key = schema_key(&doc, &path);
1560            if let Some(required) = value_at_path(&doc, &path)
1561                .and_then(|schema| schema.get("required"))
1562                .and_then(Value::as_sequence)
1563            {
1564                for property in required.iter().filter_map(Value::as_str) {
1565                    present.insert((key.clone(), property.to_owned()));
1566                }
1567            }
1568        }
1569        let missing: Vec<_> = KEEP_REQUIRED
1570            .iter()
1571            .filter(|(key, property)| {
1572                !present.contains(&((*key).to_owned(), (*property).to_owned()))
1573            })
1574            .collect();
1575        assert!(
1576            missing.is_empty(),
1577            "stale KEEP_REQUIRED entries: {missing:?}"
1578        );
1579    }
1580
1581    const CLOSED_OBJECT_SPEC: &str = r#"
1582openapi: 3.0.0
1583info:
1584  title: test
1585  version: 0.0.0
1586paths:
1587  /thing:
1588    get:
1589      responses:
1590        '200':
1591          description: ok
1592          content:
1593            application/json:
1594              schema:
1595                type: object
1596                additionalProperties: false
1597                properties:
1598                  id:
1599                    type: string
1600components:
1601  schemas:
1602    Error:
1603      type: object
1604      additionalProperties: false
1605      properties:
1606        error:
1607          type: string
1608    Open:
1609      type: object
1610      additionalProperties: true
1611"#;
1612
1613    #[test]
1614    fn closed_objects_are_opened_everywhere() {
1615        let (out, _) = normalize_spec_with_report(CLOSED_OBJECT_SPEC).unwrap();
1616        assert!(
1617            !out.contains("additionalProperties: false"),
1618            "closed object survived:\n{out}"
1619        );
1620        assert!(
1621            out.contains("additionalProperties: true"),
1622            "non-false value must be left alone:\n{out}"
1623        );
1624    }
1625}