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
86use std::collections::BTreeSet;
87
88use anyhow::{anyhow, bail, Context, Result};
89use serde_yaml::{Mapping, Value};
90
91/// Schemas known to be structural clones of `#/components/schemas/Error`.
92///
93/// Empty since the server's `Error` schema grew optional envelope fields
94/// (`details`, `status`, `fallback`, `errors`) that the minimal
95/// `WalletBalanceNotFoundError` / `WebhookNoWebhookError` shapes don't carry.
96/// Those two are still `{error, message}`-only and are referenced solely from
97/// non-2xx responses, which the error-response strip pass removes before
98/// progenitor — so they no longer need folding. Re-add a name here if a future
99/// schema is a byte-for-byte structural clone of `Error`.
100pub const ERROR_ALIASES: &[&str] = &[];
101
102const NULL_VARIANT_SCHEMA_KEYS: &[&str] = &["oneOf", "anyOf"];
103
104/// Normalize an OpenAPI YAML document: collapse [`ERROR_ALIASES`] into `Error`.
105///
106/// Returns the rewritten YAML as a string. Idempotent: running it on already
107/// normalized output is a no-op (aliases are absent, so nothing rewrites).
108pub fn normalize_spec(yaml: &str) -> Result<String> {
109    let (out, report) = normalize_with_aliases(yaml, ERROR_ALIASES)?;
110    tracing_nothing(&report); // suppress unused_variable warning when no tracing is wired
111    Ok(out)
112}
113
114/// As [`normalize_spec`] but also returns a summary of what changed.
115pub fn normalize_spec_with_report(yaml: &str) -> Result<(String, Report)> {
116    normalize_with_aliases(yaml, ERROR_ALIASES)
117}
118
119/// Core of [`normalize_spec`], parameterized on the alias list. Production
120/// callers pass [`ERROR_ALIASES`] (currently empty); the unit tests pass an
121/// explicit list so the fold and drift-check passes stay covered regardless of
122/// what the live spec needs today.
123fn normalize_with_aliases(yaml: &str, aliases: &[&str]) -> Result<(String, Report)> {
124    let mut doc: Value = serde_yaml::from_str(yaml).context("parse spec YAML")?;
125    let report = normalize_in_place(&mut doc, aliases)?;
126    let out = serde_yaml::to_string(&doc).context("re-serialize normalized spec")?;
127    Ok((out, report))
128}
129
130#[derive(Debug, Default, Clone)]
131pub struct Report {
132    /// Aliases that were folded into `Error`. Populated only for aliases that
133    /// were actually present in the input — missing aliases are silently skipped.
134    pub folded_aliases: Vec<String>,
135    /// Count of `$ref` rewrites performed across the document.
136    pub refs_rewritten: usize,
137    /// `operationId`s stripped because their only request-body variant was
138    /// multipart/form-data (progenitor 0.9 limitation).
139    pub stripped_operations: Vec<String>,
140    /// Count of non-2xx response entries removed across all operations
141    /// (progenitor 0.9 `response_types.len() <= 1` assertion).
142    pub error_responses_stripped: usize,
143    /// Count of `{type: "null"}` variants stripped from `oneOf` / `anyOf`
144    /// arrays anywhere in the document (progenitor 0.9 doesn't support
145    /// the OpenAPI 3.1 nullable-via-null-type pattern).
146    pub null_variants_stripped: usize,
147    /// Count of request bodies whose multiple `content` media types were
148    /// collapsed to a single preferred type (progenitor 0.x can't pick among
149    /// `>1` request media types — "more media types than expected").
150    pub request_media_collapsed: usize,
151    /// Count of string `enum` keywords removed from response-reachable schemas.
152    pub response_enums_stripped: usize,
153}
154
155fn normalize_in_place(doc: &mut Value, aliases: &[&str]) -> Result<Report> {
156    let mut report = Report::default();
157
158    let schemas_path = ["components", "schemas"];
159    let canonical_shape = {
160        let error = get_mapping_path(doc, &schemas_path)
161            .and_then(|m| m.get(Value::from("Error")))
162            .ok_or_else(|| anyhow!("canonical schema `Error` not found in components.schemas"))?;
163        structural_shape(error)
164    };
165
166    // Pass 1: drift-check every alias that is present.
167    let present_aliases: Vec<String> = {
168        let schemas = get_mapping_path(doc, &schemas_path)
169            .ok_or_else(|| anyhow!("components.schemas not a mapping"))?;
170        aliases
171            .iter()
172            .copied()
173            .filter(|a| schemas.contains_key(Value::from(*a)))
174            .map(str::to_owned)
175            .collect()
176    };
177
178    for alias in &present_aliases {
179        let schema = get_mapping_path(doc, &schemas_path)
180            .and_then(|m| m.get(Value::from(alias.as_str())))
181            .expect("alias presence confirmed above");
182        let alias_shape = structural_shape(schema);
183        if alias_shape != canonical_shape {
184            bail!(
185                "alias `{alias}` has drifted from canonical `Error` shape — \
186                 either restore structural equality or remove `{alias}` from ERROR_ALIASES.\n\
187                 canonical: {:#?}\nalias:     {:#?}",
188                canonical_shape,
189                alias_shape
190            );
191        }
192    }
193
194    // Pass 2: rewrite every $ref pointing at an alias.
195    for alias in &present_aliases {
196        let from = format!("#/components/schemas/{alias}");
197        let to = "#/components/schemas/Error";
198        report.refs_rewritten += rewrite_refs(doc, &from, to);
199    }
200
201    // Pass 3: drop alias definitions from components.schemas.
202    if let Some(schemas) = get_mapping_path_mut(doc, &schemas_path) {
203        for alias in &present_aliases {
204            schemas.remove(Value::from(alias.as_str()));
205        }
206    }
207    report.folded_aliases = present_aliases;
208
209    // Pass 4: strip multipart-only operations (progenitor 0.9 limitation).
210    report.stripped_operations = strip_multipart_only_operations(doc);
211
212    // Pass 4b: collapse request bodies with >1 media type to a single
213    // preferred type. Must run after the multipart strip so multipart ops are
214    // already removed rather than collapsed onto their JSON variant.
215    report.request_media_collapsed = collapse_multi_media_request_bodies(doc);
216
217    // Pass 5: strip non-2xx responses from every surviving operation
218    // (progenitor 0.9 extract_responses assertion, error side).
219    report.error_responses_stripped = strip_non_success_responses(doc);
220
221    // Pass 6: strip OpenAPI 3.1 `{type: "null"}` variants from oneOf/anyOf
222    // arrays. Progenitor 0.9 panics on this nullable pattern; the caller's
223    // hand-rolled JSON-RPC forwarder uses serde_json::Value and doesn't
224    // need a generated type for the null branch.
225    report.null_variants_stripped = strip_null_type_variants(doc);
226
227    // Pass 7: open closed objects (gh#172). See [`strip_closed_objects`].
228    strip_closed_objects(doc);
229
230    // Pass 8: open response string enums (gh#176). See [`strip_response_enums`].
231    report.response_enums_stripped = strip_response_enums(doc);
232
233    Ok(report)
234}
235
236/// Remove string `enum` keywords from schemas reachable from 2xx or `default`
237/// response bodies, following local `$ref`s transitively. Returns the count.
238///
239/// typify emits closed Rust enums that reject server-added values (gh#176).
240/// Opening response enums makes them `String`; request-body and parameter
241/// enums stay typed unless shared with a response (e.g. CompletionCriterionInput),
242/// in which case response tolerance wins. Non-string enums are left alone.
243/// Runs after the non-2xx and null-type-variant strips: error-only schemas are
244/// out of scope, and removed null branches must not make schemas reachable.
245/// `nullable: true` is preserved even when the enum contains `null`, so typify
246/// still emits `Option<String>` for nullable response values.
247fn strip_response_enums(doc: &mut Value) -> usize {
248    let mut visited = BTreeSet::new();
249    let mut enums = BTreeSet::new();
250    if let Some(paths) = doc.get("paths").and_then(Value::as_mapping) {
251        for (path, item) in paths {
252            let Some(path) = path.as_str() else { continue };
253            for verb in [
254                "get", "put", "post", "delete", "patch", "options", "head", "trace",
255            ] {
256                let Some(responses) = item
257                    .get(verb)
258                    .and_then(|op| op.get("responses"))
259                    .and_then(Value::as_mapping)
260                else {
261                    continue;
262                };
263                for code in responses
264                    .keys()
265                    .filter_map(Value::as_str)
266                    .filter(|code| is_success_or_default(code))
267                {
268                    let root = ["paths", path, verb, "responses", code]
269                        .map(str::to_owned)
270                        .to_vec();
271                    collect_response_enums(doc, &root, &mut visited, &mut enums);
272                }
273            }
274        }
275    }
276    let mut stripped = 0;
277    for path in enums {
278        let mut node = &mut *doc;
279        for key in &path {
280            node = if let Value::Sequence(seq) = node {
281                &mut seq[key.parse::<usize>().expect("collected sequence index")]
282            } else {
283                node.get_mut(key.as_str()).expect("collected schema path")
284            };
285        }
286        if node
287            .as_mapping_mut()
288            .and_then(|map| map.remove("enum"))
289            .is_some()
290        {
291            stripped += 1;
292        }
293    }
294    stripped
295}
296
297fn value_at_path<'a>(doc: &'a Value, path: &[String]) -> Option<&'a Value> {
298    path.iter().try_fold(doc, |node, key| match node {
299        Value::Sequence(seq) => seq.get(key.parse::<usize>().ok()?),
300        _ => node.get(key.as_str()),
301    })
302}
303
304fn local_ref_path(node: &Value) -> Option<Vec<String>> {
305    Some(
306        node.get("$ref")?
307            .as_str()?
308            .strip_prefix("#/")?
309            .split('/')
310            .map(|key| key.replace("~1", "/").replace("~0", "~"))
311            .collect(),
312    )
313}
314
315fn collect_response_enums(
316    doc: &Value,
317    path: &[String],
318    visited: &mut BTreeSet<Vec<String>>,
319    enums: &mut BTreeSet<Vec<String>>,
320) {
321    if !visited.insert(path.to_vec()) {
322        return;
323    }
324    let Some(response) = value_at_path(doc, path) else {
325        return;
326    };
327    if let Some(target) = local_ref_path(response) {
328        collect_response_enums(doc, &target, visited, enums);
329    }
330    if let Some(content) = response.get("content").and_then(Value::as_mapping) {
331        for media in content.keys().filter_map(Value::as_str) {
332            let mut schema_path = path.to_vec();
333            schema_path.extend(["content".to_owned(), media.to_owned(), "schema".to_owned()]);
334            collect_schema_enums(doc, &schema_path, visited, enums);
335        }
336    }
337}
338
339fn collect_schema_enums(
340    doc: &Value,
341    path: &[String],
342    visited: &mut BTreeSet<Vec<String>>,
343    enums: &mut BTreeSet<Vec<String>>,
344) {
345    if !visited.insert(path.to_vec()) {
346        return;
347    }
348    let Some(schema) = value_at_path(doc, path) else {
349        return;
350    };
351    if schema.get("type").and_then(Value::as_str) == Some("string") && schema.get("enum").is_some()
352    {
353        enums.insert(path.to_vec());
354    }
355    if let Some(target) = local_ref_path(schema) {
356        collect_schema_enums(doc, &target, visited, enums);
357    }
358    for key in [
359        "allOf",
360        "oneOf",
361        "anyOf",
362        "properties",
363        "additionalProperties",
364        "items",
365    ] {
366        let Some(child) = schema.get(key) else {
367            continue;
368        };
369        let mut child_path = path.to_vec();
370        child_path.push(key.to_owned());
371        match child {
372            Value::Sequence(seq) => {
373                for index in 0..seq.len() {
374                    let mut item_path = child_path.clone();
375                    item_path.push(index.to_string());
376                    collect_schema_enums(doc, &item_path, visited, enums);
377                }
378            }
379            Value::Mapping(map) if key == "properties" => {
380                for property in map.keys().filter_map(Value::as_str) {
381                    let mut property_path = child_path.clone();
382                    property_path.push(property.to_owned());
383                    collect_schema_enums(doc, &property_path, visited, enums);
384                }
385            }
386            _ => collect_schema_enums(doc, &child_path, visited, enums),
387        }
388    }
389}
390
391/// Recursively remove every `additionalProperties: false`.
392///
393/// typify turns that keyword into `#[serde(deny_unknown_fields)]`, so a single
394/// field the server adds to a response would fail decode in every released
395/// CLI. Stripping it everywhere (not just responses) is safe: the attribute
396/// only affects deserialization, and the CLI only ever serializes request
397/// bodies. Non-`false` values (`true`, a schema) are left alone — they change
398/// the generated type shape, not just strictness.
399fn strip_closed_objects(v: &mut Value) {
400    match v {
401        Value::Mapping(map) => {
402            if map.get("additionalProperties") == Some(&Value::Bool(false)) {
403                map.remove("additionalProperties");
404            }
405            for (_, child) in map.iter_mut() {
406                strip_closed_objects(child);
407            }
408        }
409        Value::Sequence(seq) => seq.iter_mut().for_each(strip_closed_objects),
410        _ => {}
411    }
412}
413
414/// Recursively walk `doc` and drop any `{type: "null"}` entries from `oneOf`
415/// and `anyOf` arrays. Returns the total count stripped.
416///
417/// A schema is considered a null-type variant when its `type` field equals
418/// the string `"null"` — sibling keys like `description` are tolerated so
419/// codegen tools that re-emit the spec with extra annotations still match.
420///
421/// If stripping leaves an empty `oneOf`/`anyOf`, the keyword is removed
422/// outright: an empty schema array is invalid OpenAPI and would trip the
423/// downstream generator harder than the original null-variant did.
424fn strip_null_type_variants(doc: &mut Value) -> usize {
425    fn is_null_type_schema(v: &Value) -> bool {
426        v.as_mapping()
427            .and_then(|m| m.get("type"))
428            .and_then(Value::as_str)
429            == Some("null")
430    }
431    fn walk(v: &mut Value, count: &mut usize) {
432        match v {
433            Value::Mapping(map) => {
434                for key in NULL_VARIANT_SCHEMA_KEYS {
435                    let Some(seq) = map.get_mut(*key).and_then(Value::as_sequence_mut) else {
436                        continue;
437                    };
438                    let before = seq.len();
439                    seq.retain(|item| !is_null_type_schema(item));
440                    *count += before - seq.len();
441                    if before > 0 && seq.is_empty() {
442                        map.remove(*key);
443                    }
444                }
445                for (_, child) in map.iter_mut() {
446                    walk(child, count);
447                }
448            }
449            Value::Sequence(seq) => {
450                for child in seq.iter_mut() {
451                    walk(child, count);
452                }
453            }
454            _ => {}
455        }
456    }
457    let mut count = 0;
458    walk(doc, &mut count);
459    count
460}
461
462/// Remove operations whose request body has exactly one content variant,
463/// `multipart/form-data`. Returns the removed `operationId`s (stable order).
464///
465/// We don't touch operations that declare multipart *alongside* another
466/// variant — those are handled by a preferred-content-type collapse pass that
467/// doesn't exist yet; today no such operations exist in the TaskFast spec.
468fn strip_multipart_only_operations(doc: &mut Value) -> Vec<String> {
469    let mut stripped = Vec::new();
470    let Some(paths) = doc
471        .as_mapping_mut()
472        .and_then(|m| m.get_mut(Value::from("paths")))
473        .and_then(Value::as_mapping_mut)
474    else {
475        return stripped;
476    };
477
478    const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
479    for (_path_key, path_item) in paths.iter_mut() {
480        let Some(ops) = path_item.as_mapping_mut() else {
481            continue;
482        };
483        let mut to_remove = Vec::new();
484        for verb in HTTP_VERBS {
485            if let Some(op) = ops.get(Value::from(*verb)) {
486                if operation_is_multipart_only(op) {
487                    let op_id = op
488                        .get(Value::from("operationId"))
489                        .and_then(Value::as_str)
490                        .unwrap_or("<anonymous>")
491                        .to_string();
492                    to_remove.push(*verb);
493                    stripped.push(op_id);
494                }
495            }
496        }
497        for verb in to_remove {
498            ops.remove(Value::from(verb));
499        }
500    }
501    stripped
502}
503
504/// Collapse any `requestBody.content` mapping with more than one media type
505/// down to a single preferred entry: `application/json` if present, otherwise
506/// the first declared media type (insertion order preserved). Returns the
507/// number of request bodies collapsed.
508///
509/// progenitor 0.x cannot pick among multiple request media types and panics
510/// with `more media types than expected`. Today the only affected operations
511/// are the OAuth endpoints, whose JSON and form-urlencoded variants reference
512/// the same schema, so the collapse is lossless. Multipart-bearing operations
513/// must already be stripped before this runs (see [`strip_multipart_only_operations`]).
514fn collapse_multi_media_request_bodies(doc: &mut Value) -> usize {
515    let mut collapsed = 0usize;
516    let Some(paths) = doc
517        .as_mapping_mut()
518        .and_then(|m| m.get_mut(Value::from("paths")))
519        .and_then(Value::as_mapping_mut)
520    else {
521        return 0;
522    };
523
524    const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
525    for (_path_key, path_item) in paths.iter_mut() {
526        let Some(ops) = path_item.as_mapping_mut() else {
527            continue;
528        };
529        for verb in HTTP_VERBS {
530            let Some(content) = ops
531                .get_mut(Value::from(*verb))
532                .and_then(Value::as_mapping_mut)
533                .and_then(|op| op.get_mut(Value::from("requestBody")))
534                .and_then(Value::as_mapping_mut)
535                .and_then(|body| body.get_mut(Value::from("content")))
536                .and_then(Value::as_mapping_mut)
537            else {
538                continue;
539            };
540            if content.len() <= 1 {
541                continue;
542            }
543            let keep = if content.contains_key(Value::from("application/json")) {
544                "application/json".to_string()
545            } else {
546                content
547                    .keys()
548                    .next()
549                    .and_then(Value::as_str)
550                    .map(str::to_owned)
551                    .expect("content has >1 entry")
552            };
553            content.retain(|k, _| k.as_str() == Some(keep.as_str()));
554            collapsed += 1;
555        }
556    }
557    collapsed
558}
559
560/// For each operation under `paths.*.<verb>.responses`, remove every entry
561/// whose key is not a 2xx status code or the literal `default`. Returns the
562/// total count of removed entries across the document.
563///
564/// Rationale: progenitor 0.9 asserts that both the success and error response
565/// sets have at most one distinct body type. TaskFast endpoints declare
566/// multiple distinct error shapes (`Error`, `ValidationError`, feature-specific
567/// failure envelopes). Surfacing those as typed variants in the generated
568/// client is not the generator's job here — `taskfast-client::errors::Error`
569/// reconstructs error semantics from the raw response body. Dropping non-2xx
570/// response definitions short-circuits the assertion without hiding anything
571/// the client layer doesn't already re-implement.
572fn strip_non_success_responses(doc: &mut Value) -> usize {
573    let mut removed = 0usize;
574    let Some(paths) = doc
575        .as_mapping_mut()
576        .and_then(|m| m.get_mut(Value::from("paths")))
577        .and_then(Value::as_mapping_mut)
578    else {
579        return 0;
580    };
581
582    const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
583    for (_path_key, path_item) in paths.iter_mut() {
584        let Some(ops) = path_item.as_mapping_mut() else {
585            continue;
586        };
587        for verb in HTTP_VERBS {
588            let Some(op) = ops.get_mut(Value::from(*verb)) else {
589                continue;
590            };
591            let Some(responses) = op
592                .as_mapping_mut()
593                .and_then(|m| m.get_mut(Value::from("responses")))
594                .and_then(Value::as_mapping_mut)
595            else {
596                continue;
597            };
598
599            let to_remove: Vec<Value> = responses
600                .iter()
601                .filter_map(|(k, _)| {
602                    let key = k.as_str()?;
603                    if is_success_or_default(key) {
604                        None
605                    } else {
606                        Some(k.clone())
607                    }
608                })
609                .collect();
610            for k in to_remove {
611                if responses.remove(&k).is_some() {
612                    removed += 1;
613                }
614            }
615        }
616    }
617    removed
618}
619
620fn is_success_or_default(code: &str) -> bool {
621    if code == "default" {
622        return true;
623    }
624    // 2XX ranges (e.g. "2XX") and explicit codes 200..=299.
625    match code.chars().next() {
626        Some('2') => true,
627        _ => false,
628    }
629}
630
631fn operation_is_multipart_only(op: &Value) -> bool {
632    let Some(body) = op
633        .as_mapping()
634        .and_then(|m| m.get(Value::from("requestBody")))
635    else {
636        return false;
637    };
638    let Some(content) = body
639        .as_mapping()
640        .and_then(|m| m.get(Value::from("content")))
641        .and_then(Value::as_mapping)
642    else {
643        return false;
644    };
645    let keys: Vec<&str> = content.keys().filter_map(Value::as_str).collect();
646    // Match either (a) sole multipart variant or (b) any operation that
647    // declares multipart alongside other variants — progenitor 0.9 trips on
648    // multi-variant bodies too (`more media types than expected`), and the
649    // upload path is hand-rolled in taskfast-client regardless.
650    keys.contains(&"multipart/form-data")
651}
652
653/// Structural projection: strip doc-only fields so two schemas differing only
654/// in `description`/`example` compare equal.
655fn structural_shape(v: &Value) -> Value {
656    match v {
657        Value::Mapping(m) => {
658            let mut out = Mapping::new();
659            for (k, val) in m {
660                let keep = match k.as_str() {
661                    Some("description") | Some("example") | Some("summary") | Some("default")
662                    | Some("deprecated") | Some("title") => false,
663                    _ => true,
664                };
665                if keep {
666                    out.insert(k.clone(), structural_shape(val));
667                }
668            }
669            Value::Mapping(out)
670        }
671        Value::Sequence(s) => Value::Sequence(s.iter().map(structural_shape).collect()),
672        other => other.clone(),
673    }
674}
675
676/// Walk `doc`, replacing every `$ref` whose value equals `from` with `to`.
677/// Returns the number of rewrites performed.
678fn rewrite_refs(doc: &mut Value, from: &str, to: &str) -> usize {
679    let mut count = 0;
680    rewrite_refs_inner(doc, from, to, &mut count);
681    count
682}
683
684fn rewrite_refs_inner(v: &mut Value, from: &str, to: &str, count: &mut usize) {
685    match v {
686        Value::Mapping(m) => {
687            // A $ref object is typically `{"$ref": "..."}` — a single-key mapping.
688            // We still walk siblings because spec-extension keys may coexist.
689            if let Some(target) = m.get_mut(Value::from("$ref")) {
690                if target.as_str() == Some(from) {
691                    *target = Value::from(to);
692                    *count += 1;
693                }
694            }
695            for (_, val) in m.iter_mut() {
696                rewrite_refs_inner(val, from, to, count);
697            }
698        }
699        Value::Sequence(s) => {
700            for val in s.iter_mut() {
701                rewrite_refs_inner(val, from, to, count);
702            }
703        }
704        _ => {}
705    }
706}
707
708fn get_mapping_path<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Mapping> {
709    let mut cur = v;
710    for key in path {
711        cur = cur.as_mapping()?.get(Value::from(*key))?;
712    }
713    cur.as_mapping()
714}
715
716fn get_mapping_path_mut<'a>(v: &'a mut Value, path: &[&str]) -> Option<&'a mut Mapping> {
717    let mut cur = v;
718    for key in path {
719        cur = cur.as_mapping_mut()?.get_mut(Value::from(*key))?;
720    }
721    cur.as_mapping_mut()
722}
723
724fn tracing_nothing(_r: &Report) {}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729
730    const BASE_SPEC: &str = r#"
731openapi: 3.0.0
732info:
733  title: test
734  version: 0.0.0
735paths:
736  /foo:
737    get:
738      responses:
739        '200':
740          description: ok
741        '404':
742          description: not found
743          content:
744            application/json:
745              schema:
746                $ref: '#/components/schemas/WalletBalanceNotFoundError'
747        '503':
748          description: unavailable
749          content:
750            application/json:
751              schema:
752                $ref: '#/components/schemas/Error'
753components:
754  schemas:
755    Error:
756      type: object
757      required:
758        - error
759        - message
760      properties:
761        error:
762          type: string
763          description: Error code
764        message:
765          type: string
766          description: Human message
767    WalletBalanceNotFoundError:
768      type: object
769      required:
770        - error
771        - message
772      properties:
773        error:
774          type: string
775          description: Error code
776          example: "wallet_not_applicable"
777        message:
778          type: string
779          description: Human message
780          example: "not a tempo agent"
781    Untouched:
782      type: object
783"#;
784
785    #[test]
786    fn normalize_folds_alias_refs_and_drops_schema() {
787        let (out, report) =
788            normalize_with_aliases(BASE_SPEC, &["WalletBalanceNotFoundError"]).unwrap();
789        assert_eq!(
790            report.folded_aliases,
791            vec!["WalletBalanceNotFoundError".to_string()]
792        );
793        assert_eq!(report.refs_rewritten, 1);
794
795        // The canonical Error schema definition still lives in components
796        // (non-2xx response strip removes the *usages*, not the shared type).
797        assert!(
798            out.contains("Error:\n      type: object"),
799            "canonical Error schema missing:\n{out}"
800        );
801        assert!(
802            !out.contains("WalletBalanceNotFoundError"),
803            "alias name fully removed, got:\n{out}"
804        );
805
806        // Siblings untouched.
807        assert!(out.contains("Untouched:"));
808    }
809
810    #[test]
811    fn idempotent_when_no_aliases_present() {
812        let (out1, _) = normalize_spec_with_report(BASE_SPEC).unwrap();
813        let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
814        assert_eq!(out1, out2);
815        assert!(report2.folded_aliases.is_empty());
816        assert_eq!(report2.refs_rewritten, 0);
817    }
818
819    #[test]
820    fn drift_check_rejects_mismatched_alias() {
821        // Same as BASE_SPEC but WalletBalanceNotFoundError gets an extra required field.
822        let drifted = BASE_SPEC.replace(
823            "WalletBalanceNotFoundError:\n      type: object\n      required:\n        - error\n        - message",
824            "WalletBalanceNotFoundError:\n      type: object\n      required:\n        - error\n        - message\n        - drift_field",
825        );
826        let err = normalize_with_aliases(&drifted, &["WalletBalanceNotFoundError"]).unwrap_err();
827        let msg = format!("{err:#}");
828        assert!(msg.contains("drifted from canonical"), "got: {msg}");
829    }
830
831    #[test]
832    fn missing_error_schema_is_error() {
833        let bad = BASE_SPEC.replace("Error:", "NotError:");
834        let err = normalize_spec(bad.as_str()).unwrap_err();
835        assert!(format!("{err:#}").contains("canonical schema `Error` not found"));
836    }
837
838    const MULTIPART_SPEC: &str = r#"
839openapi: 3.0.0
840info: { title: test, version: 0.0.0 }
841paths:
842  /upload:
843    post:
844      operationId: uploadThing
845      requestBody:
846        required: true
847        content:
848          multipart/form-data:
849            schema:
850              type: object
851              required: [file]
852              properties:
853                file: { type: string, format: binary }
854      responses:
855        '201': { description: ok }
856    get:
857      operationId: listThings
858      responses:
859        '200': { description: ok }
860  /json_only:
861    post:
862      operationId: postJsonThing
863      requestBody:
864        required: true
865        content:
866          application/json:
867            schema: { type: object }
868      responses:
869        '201': { description: ok }
870components:
871  schemas:
872    Error:
873      type: object
874      required: [error, message]
875      properties:
876        error: { type: string }
877        message: { type: string }
878"#;
879
880    #[test]
881    fn non_success_responses_are_stripped() {
882        let (out, report) = normalize_spec_with_report(BASE_SPEC).unwrap();
883        // BASE_SPEC has one op with 200/404/503 — 404 and 503 must go.
884        assert_eq!(report.error_responses_stripped, 2);
885        // The success code survives.
886        assert!(out.contains("'200':"));
887        // Error codes are gone.
888        assert!(!out.contains("'404':"), "404 not stripped:\n{out}");
889        assert!(!out.contains("'503':"), "503 not stripped:\n{out}");
890    }
891
892    #[test]
893    fn multi_media_request_body_collapses_to_json() {
894        const SPEC: &str = r#"
895openapi: 3.0.0
896info: { title: test, version: 0.0.0 }
897paths:
898  /oauth2/token:
899    post:
900      operationId: oauthToken
901      requestBody:
902        required: true
903        content:
904          application/json:
905            schema: { $ref: '#/components/schemas/Tok' }
906          application/x-www-form-urlencoded:
907            schema: { $ref: '#/components/schemas/Tok' }
908      responses:
909        '200': { description: ok }
910  /forms:
911    post:
912      operationId: formOnly
913      requestBody:
914        content:
915          application/x-www-form-urlencoded:
916            schema: { type: object }
917          text/plain:
918            schema: { type: string }
919      responses:
920        '200': { description: ok }
921components:
922  schemas:
923    Error:
924      type: object
925      required: [error, message]
926      properties:
927        error: { type: string }
928        message: { type: string }
929    Tok:
930      type: object
931"#;
932        let (out, report) = normalize_spec_with_report(SPEC).unwrap();
933        // Both multi-media request bodies collapse to a single type.
934        assert_eq!(report.request_media_collapsed, 2);
935        // oauthToken: JSON preferred — exactly one json media type remains
936        // (its form variant is gone).
937        assert_eq!(
938            out.matches("application/json:").count(),
939            1,
940            "expected oauth body kept as json:\n{out}"
941        );
942        // formOnly: no JSON, so the first declared type (form) wins and the
943        // text/plain variant is dropped.
944        assert_eq!(
945            out.matches("application/x-www-form-urlencoded:").count(),
946            1,
947            "expected form-only body kept as form-urlencoded:\n{out}"
948        );
949        assert!(
950            !out.contains("text/plain"),
951            "lower-priority text/plain variant not collapsed:\n{out}"
952        );
953    }
954
955    #[test]
956    fn multipart_only_operation_is_stripped() {
957        let (out, report) = normalize_spec_with_report(MULTIPART_SPEC).unwrap();
958        assert_eq!(report.stripped_operations, vec!["uploadThing".to_string()]);
959        assert!(!out.contains("uploadThing"), "uploadThing not stripped");
960        // Siblings untouched.
961        assert!(out.contains("listThings"), "sibling GET removed by mistake");
962        assert!(
963            out.contains("postJsonThing"),
964            "JSON-only POST removed by mistake"
965        );
966        // The path entry itself survives because listThings still lives there.
967        assert!(out.contains("/upload:"));
968    }
969
970    const NULL_VARIANT_SPEC: &str = r#"
971openapi: 3.0.0
972info: { title: test, version: 0.0.0 }
973paths:
974  /thing:
975    get:
976      responses:
977        '200':
978          description: ok
979          content:
980            application/json:
981              schema:
982                oneOf:
983                  - { type: object, properties: { name: { type: string } } }
984                  - { type: "null" }
985components:
986  schemas:
987    Error:
988      type: object
989      required: [error, message]
990      properties:
991        error: { type: string }
992        message: { type: string }
993    Nullable:
994      anyOf:
995        - { type: integer }
996        - { type: "null", description: "explicit absence" }
997    AllNull:
998      oneOf:
999        - { type: "null" }
1000        - { type: "null" }
1001    Untouched:
1002      oneOf:
1003        - { type: integer }
1004        - { type: string }
1005"#;
1006
1007    #[test]
1008    fn null_type_variants_are_stripped_and_counted() {
1009        let (out, report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
1010        // 1 in /thing oneOf + 1 in Nullable.anyOf + 2 in AllNull.oneOf = 4.
1011        assert_eq!(report.null_variants_stripped, 4);
1012
1013        // Surviving variants kept.
1014        assert!(out.contains("Nullable:"));
1015        assert!(out.contains("Untouched:"));
1016        // Sibling keys (description) on a null-type variant don't shield it.
1017        assert!(
1018            !out.contains("explicit absence"),
1019            "null variant with sibling description not stripped:\n{out}"
1020        );
1021        // Untouched non-null oneOf preserved verbatim.
1022        assert!(
1023            out.contains("Untouched:") && out.matches("type: string").count() >= 2,
1024            "Untouched.oneOf altered:\n{out}"
1025        );
1026    }
1027
1028    #[test]
1029    fn empty_oneof_after_strip_is_removed_outright() {
1030        let (out, _report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
1031        // AllNull had only null variants; the oneOf key must be gone, leaving
1032        // the (now-empty) schema definition without an invalid `oneOf: []`.
1033        // Find the AllNull block and assert no oneOf inside it.
1034        let idx = out.find("AllNull:").expect("AllNull schema present");
1035        let tail = &out[idx..];
1036        // Stop at the next sibling schema or end-of-doc.
1037        let block_end = tail[8..].find("\n    ").map_or(tail.len(), |n| n + 8);
1038        let block = &tail[..block_end];
1039        assert!(
1040            !block.contains("oneOf"),
1041            "empty oneOf not removed from AllNull:\n{block}"
1042        );
1043    }
1044
1045    #[test]
1046    fn null_strip_is_idempotent() {
1047        let (out1, _) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
1048        let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
1049        assert_eq!(out1, out2);
1050        assert_eq!(report2.null_variants_stripped, 0);
1051    }
1052
1053    #[test]
1054    fn response_enums_are_opened_but_request_enums_stay_typed() {
1055        const SPEC: &str = r#"
1056openapi: 3.0.0
1057info: { title: test, version: 0.0.0 }
1058paths:
1059  /thing:
1060    post:
1061      parameters:
1062        - { name: filter, in: query, schema: { type: string, enum: [known] } }
1063      requestBody:
1064        content:
1065          application/json:
1066            schema: { $ref: '#/components/schemas/Request' }
1067      responses:
1068        '200':
1069          description: ok
1070          content:
1071            application/json:
1072              schema: { $ref: '#/components/schemas/Response' }
1073        default:
1074          description: fallback
1075          content:
1076            application/json:
1077              schema: { type: string, enum: [fallback] }
1078        '400':
1079          description: bad request
1080          content:
1081            application/json:
1082              schema: { $ref: '#/components/schemas/Error' }
1083components:
1084  schemas:
1085    Error: { type: string, enum: [bad] }
1086    Request:
1087      type: object
1088      properties:
1089        kind: { type: string, enum: [known] }
1090        shared: { $ref: '#/components/schemas/Shared' }
1091    Shared: { type: string, enum: [known] }
1092    Response:
1093      allOf:
1094        - type: object
1095          properties:
1096            status: { type: string, enum: [active] }
1097            nullable_status: { type: string, nullable: true, enum: [active, null] }
1098            shared: { $ref: '#/components/schemas/Shared' }
1099            recursive: { $ref: '#/components/schemas/Response' }
1100            choices:
1101              oneOf:
1102                - { $ref: '#/components/schemas/Choice' }
1103                - { type: "null" }
1104            alternatives:
1105              anyOf:
1106                - { type: string, enum: [known] }
1107            list:
1108              type: array
1109              items: { $ref: '#/components/schemas/Choice' }
1110            map:
1111              type: object
1112              additionalProperties: { $ref: '#/components/schemas/Choice' }
1113            number: { type: integer, enum: [1, 2] }
1114    Choice: { type: string, enum: [known] }
1115"#;
1116        let (out, _) = normalize_spec_with_report(SPEC).unwrap();
1117        let doc: Value = serde_yaml::from_str(&out).unwrap();
1118        let schemas = &doc["components"]["schemas"];
1119        let properties = &schemas["Response"]["allOf"][0]["properties"];
1120        assert!(properties["status"].get("enum").is_none());
1121        assert!(properties["nullable_status"].get("enum").is_none());
1122        assert_eq!(properties["nullable_status"]["nullable"], Value::Bool(true));
1123        assert!(schemas["Request"]["properties"]["kind"]
1124            .get("enum")
1125            .is_some());
1126        assert!(doc["paths"]["/thing"]["post"]["parameters"][0]["schema"]
1127            .get("enum")
1128            .is_some());
1129        assert!(schemas["Shared"].get("enum").is_none());
1130        assert!(schemas["Choice"].get("enum").is_none());
1131        assert!(properties["alternatives"]["anyOf"][0].get("enum").is_none());
1132        assert!(properties["number"].get("enum").is_some());
1133        assert!(schemas["Error"].get("enum").is_some());
1134        assert!(
1135            doc["paths"]["/thing"]["post"]["responses"]["default"]["content"]["application/json"]
1136                ["schema"]
1137                .get("enum")
1138                .is_none()
1139        );
1140    }
1141
1142    #[test]
1143    fn reusable_responses_follow_refs_without_opening_example_enums() {
1144        const SPEC: &str = r#"
1145openapi: 3.0.0
1146info: { title: test, version: 0.0.0 }
1147paths:
1148  /thing:
1149    get:
1150      responses:
1151        '2XX': { $ref: '#/components/responses/Thing' }
1152components:
1153  responses:
1154    Thing:
1155      description: ok
1156      content:
1157        application/json:
1158          schema: { $ref: '#/components/schemas/Thing~1Status~0' }
1159          example: { type: string, enum: [literal_payload] }
1160  schemas:
1161    Error: { type: object }
1162    Thing/Status~: { type: string, enum: [active] }
1163"#;
1164        let (out, report) = normalize_spec_with_report(SPEC).unwrap();
1165        let doc: Value = serde_yaml::from_str(&out).unwrap();
1166        assert!(doc["components"]["schemas"]["Thing/Status~"]
1167            .get("enum")
1168            .is_none());
1169        assert!(
1170            doc["components"]["responses"]["Thing"]["content"]["application/json"]["example"]
1171                .get("enum")
1172                .is_some()
1173        );
1174        assert_eq!(report.response_enums_stripped, 1);
1175    }
1176
1177    #[test]
1178    fn live_spec_response_enum_inventory_is_normalized() {
1179        // Independent inventory of the vendored spec, including transitive refs.
1180        // Counts are measured here, not used as limits by the production pass.
1181        fn enum_count(node: &Value) -> usize {
1182            match node {
1183                Value::Mapping(map) => {
1184                    usize::from(
1185                        map.get("type").and_then(Value::as_str) == Some("string")
1186                            && map.contains_key("enum"),
1187                    ) + map.values().map(enum_count).sum::<usize>()
1188                }
1189                Value::Sequence(seq) => seq.iter().map(enum_count).sum(),
1190                _ => 0,
1191            }
1192        }
1193        fn refs(node: &Value, schemas: &Mapping, found: &mut BTreeSet<String>) {
1194            match node {
1195                Value::Mapping(map) => {
1196                    if let Some(name) = map
1197                        .get("$ref")
1198                        .and_then(Value::as_str)
1199                        .and_then(|target| target.strip_prefix("#/components/schemas/"))
1200                    {
1201                        if found.insert(name.to_owned()) {
1202                            refs(&schemas[name], schemas, found);
1203                        }
1204                    }
1205                    for child in map.values() {
1206                        refs(child, schemas, found);
1207                    }
1208                }
1209                Value::Sequence(seq) => {
1210                    for child in seq {
1211                        refs(child, schemas, found);
1212                    }
1213                }
1214                _ => {}
1215            }
1216        }
1217        let src = include_str!("../../../spec/openapi.yaml");
1218        let doc: Value = serde_yaml::from_str(src).unwrap();
1219        let schemas = doc["components"]["schemas"].as_mapping().unwrap();
1220        let mut response_refs = BTreeSet::new();
1221        let mut request_refs = BTreeSet::new();
1222        let mut inline_response_ops = 0;
1223        let mut inline_request_ops = 0;
1224        let mut inline_response_enums = 0;
1225        for item in doc["paths"].as_mapping().unwrap().values() {
1226            refs(&item["parameters"], schemas, &mut request_refs);
1227            for verb in [
1228                "get", "put", "post", "delete", "patch", "options", "head", "trace",
1229            ] {
1230                let Some(op) = item.get(verb) else { continue };
1231                refs(&op["requestBody"], schemas, &mut request_refs);
1232                refs(&op["parameters"], schemas, &mut request_refs);
1233                inline_request_ops +=
1234                    usize::from(enum_count(&op["requestBody"]) + enum_count(&op["parameters"]) > 0);
1235                let mut op_enums = 0;
1236                for (code, response) in op["responses"].as_mapping().unwrap() {
1237                    let code = code.as_str().unwrap();
1238                    if code == "default" || code.starts_with('2') {
1239                        refs(&response["content"], schemas, &mut response_refs);
1240                        op_enums += enum_count(&response["content"]);
1241                    }
1242                }
1243                inline_response_ops += usize::from(op_enums > 0);
1244                inline_response_enums += op_enums;
1245            }
1246        }
1247        let enum_components: BTreeSet<String> = schemas
1248            .iter()
1249            .filter(|(_, schema)| enum_count(schema) > 0)
1250            .map(|(name, _)| name.as_str().unwrap().to_owned())
1251            .collect();
1252        let response_components: BTreeSet<_> = enum_components
1253            .intersection(&response_refs)
1254            .cloned()
1255            .collect();
1256        let request_components: BTreeSet<_> = enum_components
1257            .intersection(&request_refs)
1258            .cloned()
1259            .collect();
1260        assert_eq!(
1261            response_components.difference(&request_components).count(),
1262            50
1263        );
1264        assert_eq!(
1265            request_components.difference(&response_components).count(),
1266            8
1267        );
1268        assert_eq!(
1269            response_components
1270                .intersection(&request_components)
1271                .map(String::as_str)
1272                .collect::<Vec<_>>(),
1273            ["CompletionCriterionInput"]
1274        );
1275        assert_eq!(
1276            enum_components
1277                .difference(
1278                    &response_components
1279                        .union(&request_components)
1280                        .cloned()
1281                        .collect()
1282                )
1283                .map(String::as_str)
1284                .collect::<Vec<_>>(),
1285            ["RateLimitError"]
1286        );
1287        assert_eq!(inline_response_ops, 3);
1288        assert_eq!(inline_request_ops, 10);
1289        let expected = inline_response_enums
1290            + response_components
1291                .iter()
1292                .map(|name| enum_count(&schemas[name.as_str()]))
1293                .sum::<usize>();
1294        let (out, report) = normalize_spec_with_report(src).unwrap();
1295        assert_eq!(report.response_enums_stripped, expected);
1296        let normalized: Value = serde_yaml::from_str(&out).unwrap();
1297        for name in &response_components {
1298            assert_eq!(
1299                enum_count(&normalized["components"]["schemas"][name.as_str()]),
1300                0,
1301                "response component {name}"
1302            );
1303        }
1304        for name in request_components.difference(&response_components) {
1305            assert_eq!(
1306                enum_count(&normalized["components"]["schemas"][name.as_str()]),
1307                enum_count(&schemas[name.as_str()]),
1308                "request-only component {name}"
1309            );
1310        }
1311        let (again, report) = normalize_spec_with_report(&out).unwrap();
1312        assert_eq!(again, out);
1313        assert_eq!(report.response_enums_stripped, 0);
1314    }
1315
1316    const CLOSED_OBJECT_SPEC: &str = r#"
1317openapi: 3.0.0
1318info:
1319  title: test
1320  version: 0.0.0
1321paths:
1322  /thing:
1323    get:
1324      responses:
1325        '200':
1326          description: ok
1327          content:
1328            application/json:
1329              schema:
1330                type: object
1331                additionalProperties: false
1332                properties:
1333                  id:
1334                    type: string
1335components:
1336  schemas:
1337    Error:
1338      type: object
1339      additionalProperties: false
1340      properties:
1341        error:
1342          type: string
1343    Open:
1344      type: object
1345      additionalProperties: true
1346"#;
1347
1348    #[test]
1349    fn closed_objects_are_opened_everywhere() {
1350        let (out, _) = normalize_spec_with_report(CLOSED_OBJECT_SPEC).unwrap();
1351        assert!(
1352            !out.contains("additionalProperties: false"),
1353            "closed object survived:\n{out}"
1354        );
1355        assert!(
1356            out.contains("additionalProperties: true"),
1357            "non-false value must be left alone:\n{out}"
1358        );
1359    }
1360}