Skip to main content

xtask/codex_schema/
merge.rs

1//! Core schema-merging logic - a faithful Rust port of the definitions-
2//! combining half of the former `schema/build_combined_schema.py` (see git
3//! history for the original). Reused by both `regen` (the real merge) and
4//! `bisect` (building opaqued-out candidate schemas from the same merge).
5
6use std::collections::BTreeSet;
7
8use anyhow::{Context, Result, bail};
9use serde_json::{Map, Value, json};
10
11use super::naming;
12
13/// Recursively rewrites `"$ref": "#/definitions/v2/X"` to `"#/definitions/X"`
14/// so refs resolve against a single flat `definitions` namespace.
15pub fn rewrite_v2_refs(value: &Value) -> Value {
16    match value {
17        Value::Object(map) => {
18            let mut out = Map::with_capacity(map.len());
19            for (k, v) in map {
20                if k == "$ref"
21                    && let Value::String(s) = v
22                    && let Some(rest) = s.strip_prefix("#/definitions/v2/")
23                {
24                    out.insert(k.clone(), Value::String(format!("#/definitions/{rest}")));
25                    continue;
26                }
27                out.insert(k.clone(), rewrite_v2_refs(v));
28            }
29            Value::Object(out)
30        }
31        Value::Array(items) => Value::Array(items.iter().map(rewrite_v2_refs).collect()),
32        other => other.clone(),
33    }
34}
35
36/// The `McpServerElicitationRequestParams` typify-0.7.0 workaround: merges a
37/// schema's top-level object `properties`/`required` into each sibling
38/// `oneOf` branch, producing a pure oneOf-of-self-contained-objects.
39/// Returns the schema unchanged unless it actually has this exact shape
40/// (top-level `oneOf` and `properties` both present).
41///
42/// Every `oneOf` branch must itself be an inline `{"type": "object", ...}`
43/// schema with a `properties` object (`required` may be absent, meaning no
44/// branch-specific required fields) - bails otherwise rather than silently
45/// treating an unrecognized branch shape (e.g. a bare `$ref` to a named
46/// branch type) as if it contributed no fields. Silently defaulting a branch
47/// to empty would let `typify` accept the resulting schema while silently
48/// dropping every field that branch's `$ref` would have contributed, with no
49/// build failure.
50pub fn flatten_base_plus_oneof(schema: &Value) -> Result<Value> {
51    let Some(obj) = schema.as_object() else {
52        return Ok(schema.clone());
53    };
54    if !(obj.contains_key("oneOf") && obj.contains_key("properties")) {
55        return Ok(schema.clone());
56    }
57
58    let base_props = obj
59        .get("properties")
60        .and_then(Value::as_object)
61        .cloned()
62        .unwrap_or_default();
63    let base_required: BTreeSet<String> = obj
64        .get("required")
65        .and_then(Value::as_array)
66        .map(|a| {
67            a.iter()
68                .filter_map(|v| v.as_str().map(String::from))
69                .collect()
70        })
71        .unwrap_or_default();
72    let one_of = obj
73        .get("oneOf")
74        .and_then(Value::as_array)
75        .cloned()
76        .unwrap_or_default();
77
78    let mut flattened = Vec::with_capacity(one_of.len());
79    for (i, branch) in one_of.iter().enumerate() {
80        let branch_obj = branch.as_object().with_context(|| {
81            format!(
82                "flatten_base_plus_oneof: oneOf branch {i} is not an inline object schema ({branch}) \
83                 - this workaround only knows how to flatten inline `{{\"type\": \"object\", ...}}` \
84                 branches. Update flatten_base_plus_oneof to handle this shape (e.g. a $ref branch)."
85            )
86        })?;
87        if branch_obj.get("type").and_then(Value::as_str) != Some("object") {
88            bail!(
89                "flatten_base_plus_oneof: oneOf branch {i} is not \"type\": \"object\" ({branch}) - \
90                 refusing to flatten a branch shape this workaround wasn't written for."
91            );
92        }
93        if !branch_obj.contains_key("properties") {
94            bail!(
95                "flatten_base_plus_oneof: oneOf branch {i} has no \"properties\" key ({branch}) - \
96                 refusing to flatten, since that would silently contribute zero fields from this \
97                 branch."
98            );
99        }
100
101        // {**base_props, **branch.properties}: base first (its order), then
102        // branch entries override/append - matches Python dict-merge.
103        let mut merged_props = base_props.clone();
104        if let Some(bp) = branch_obj.get("properties").and_then(Value::as_object) {
105            for (k, v) in bp {
106                merged_props.insert(k.clone(), v.clone());
107            }
108        }
109
110        // sorted(set(base_required) | set(branch_required))
111        let mut merged_required = base_required.clone();
112        if let Some(br) = branch_obj.get("required").and_then(Value::as_array) {
113            merged_required.extend(br.iter().filter_map(|v| v.as_str().map(String::from)));
114        }
115
116        let mut branch_out = Map::new();
117        branch_out.insert("type".to_string(), json!("object"));
118        branch_out.insert("properties".to_string(), Value::Object(merged_props));
119        branch_out.insert(
120            "required".to_string(),
121            Value::Array(merged_required.into_iter().map(Value::String).collect()),
122        );
123        flattened.push(Value::Object(branch_out));
124    }
125
126    let mut out = Map::new();
127    out.insert(
128        "title".to_string(),
129        obj.get("title").cloned().unwrap_or(Value::Null),
130    );
131    out.insert("oneOf".to_string(), Value::Array(flattened));
132    Ok(Value::Object(out))
133}
134
135/// Definition names known to legitimately differ between the master and v2
136/// bundles - v2's copy is the deliberately-pruned/authoritative one and is
137/// *expected* to differ from master's (ref-rewritten) copy. Any other
138/// name-collision with differing content is treated as a hard failure (see
139/// `check_collision_compatibility`) rather than silently letting v2 shadow a
140/// master definition that might carry different meaning.
141const EXPECTED_DIVERGENT_COLLISIONS: &[&str] =
142    &["ClientRequest", "ServerNotification", "RequestId"];
143
144/// For every definition name present in both `master_flat_rewritten` and
145/// `v2_defs`, verifies they're either identical or on the explicit
146/// known-divergent allowlist. `build_combined` always prefers v2's copy on a
147/// collision; this only exists to catch collisions where that's *not* safe -
148/// a future codex schema change that makes some other same-named type
149/// genuinely diverge between the two bundles would otherwise have v2 silently
150/// substitute the wrong shape into a master-derived envelope type (several of
151/// which transitively `$ref` into collision names like `AbsolutePathBuf` and
152/// `RequestId`), with no build failure and no warning.
153fn check_collision_compatibility(
154    master_flat_rewritten: &Map<String, Value>,
155    v2_defs: &Map<String, Value>,
156) -> Result<()> {
157    for (name, master_def) in master_flat_rewritten {
158        let Some(v2_def) = v2_defs.get(name) else {
159            continue;
160        };
161        if master_def == v2_def {
162            continue;
163        }
164        if EXPECTED_DIVERGENT_COLLISIONS.contains(&name.as_str()) {
165            continue;
166        }
167        bail!(
168            "build_combined: definition {name:?} exists in both the master and v2 schema bundles \
169             with DIFFERENT content, and is not in EXPECTED_DIVERGENT_COLLISIONS. v2 always wins \
170             collisions, so this would silently substitute v2's copy of {name:?} into any \
171             master-derived type that references it - which may be wrong if the two copies now mean \
172             different things. Either this is a legitimate new divergence (add {name:?} to \
173             EXPECTED_DIVERGENT_COLLISIONS with a comment explaining why), or something has gone \
174             wrong upstream in how codex generates these two bundles."
175        );
176    }
177    Ok(())
178}
179
180/// Merges the master bundle's flat top-level definitions (ref-rewritten)
181/// with the v2 bundle's definitions (v2 wins name collisions), applies the
182/// `McpServerElicitationRequestParams` flatten workaround, and wraps the
183/// result in the same schema envelope `build_combined_schema.py` produced.
184pub fn build_combined(master: &Value, v2: &Value) -> Result<Value> {
185    let master_defs = master
186        .get("definitions")
187        .and_then(Value::as_object)
188        .context("master bundle missing top-level \"definitions\" object")?;
189    let v2_defs = v2
190        .get("definitions")
191        .and_then(Value::as_object)
192        .context("v2 bundle missing top-level \"definitions\" object")?;
193
194    let mut master_flat_rewritten: Map<String, Value> = Map::new();
195    for (k, v) in master_defs {
196        if k == "v2" {
197            continue;
198        }
199        master_flat_rewritten.insert(k.clone(), rewrite_v2_refs(v));
200    }
201
202    check_collision_compatibility(&master_flat_rewritten, v2_defs)?;
203
204    let mut combined_defs = master_flat_rewritten;
205    for (k, v) in v2_defs {
206        // v2 wins name collisions - `Map::insert` on an existing key updates
207        // its value, matching Python's `{**master_flat_rewritten,
208        // **v2["definitions"]}` merge semantics. Iteration order of the
209        // result is NOT meaningful (see xtask/Cargo.toml's serde_json entry -
210        // `preserve_order` was removed after it leaked into unrelated
211        // generated docs), only which value wins per key.
212        combined_defs.insert(k.clone(), v.clone());
213    }
214
215    if let Some(schema) = combined_defs
216        .get("McpServerElicitationRequestParams")
217        .cloned()
218    {
219        combined_defs.insert(
220            "McpServerElicitationRequestParams".to_string(),
221            flatten_base_plus_oneof(&schema)?,
222        );
223    }
224
225    Ok(wrap_definitions(combined_defs))
226}
227
228/// Wraps a flat `definitions` map in the schema envelope the crate's
229/// `build.rs` expects (`$schema`/`title`/`description`/`type`/`definitions`,
230/// in that field order).
231pub fn wrap_definitions(definitions: Map<String, Value>) -> Value {
232    let mut out = Map::new();
233    out.insert(
234        "$schema".to_string(),
235        json!("http://json-schema.org/draft-07/schema#"),
236    );
237    out.insert("title".to_string(), json!("CodexAppServerProtocolCombined"));
238    out.insert(
239        "description".to_string(),
240        json!(
241            "Merged, flat-ref, self-contained v2-only Codex app-server protocol schema \
242             (master envelope/ServerRequest/ClientNotification types ref-rewritten to flat \
243             + v2 client-request/notification surface). Generated by \
244             `cargo xtask codex-schema regen`."
245        ),
246    );
247    out.insert("type".to_string(), json!("object"));
248    out.insert("definitions".to_string(), Value::Object(definitions));
249    Value::Object(out)
250}
251
252/// Extracts the `method` string of every `oneOf` branch of a discriminated
253/// union definition (e.g. `ClientRequest`), in schema order.
254pub fn methods_of(union_def: &Value) -> Result<Vec<String>> {
255    let one_of = union_def
256        .get("oneOf")
257        .and_then(Value::as_array)
258        .context("union definition missing \"oneOf\" array")?;
259    one_of
260        .iter()
261        .map(|entry| {
262            entry
263                .pointer("/properties/method/enum/0")
264                .and_then(Value::as_str)
265                .map(str::to_string)
266                .context("oneOf entry missing properties.method.enum[0]")
267        })
268        .collect()
269}
270
271/// The resolved params type for one method's `oneOf` branch: the referenced
272/// type name (if any) and whether the params were nullable (2-way anyOf).
273#[derive(Debug)]
274pub struct ParamsType {
275    pub type_name: Option<String>,
276    pub optional: bool,
277}
278
279/// STRICT: only a plain `$ref`, a 2-way nullable-ref `anyOf`, an explicit
280/// `{"type": "null"}`, or a missing `"params"` key are recognized. Any other
281/// shape is a hard failure, matching the Python original's
282/// `params_type_for` docstring: raising here (rather than silently falling
283/// back to "no params") is load-bearing - the wrapper codegen in `build.rs`
284/// would otherwise silently emit `params: ()` for a method that actually
285/// requires typed params.
286pub fn params_type_for(method: &str, union_def: &Value) -> Result<ParamsType> {
287    let one_of = union_def
288        .get("oneOf")
289        .and_then(Value::as_array)
290        .context("union definition missing \"oneOf\" array")?;
291
292    for entry in one_of {
293        let entry_method = entry
294            .pointer("/properties/method/enum/0")
295            .and_then(Value::as_str);
296        if entry_method != Some(method) {
297            continue;
298        }
299
300        let Some(params_schema) = entry.pointer("/properties/params") else {
301            return Ok(ParamsType {
302                type_name: None,
303                optional: false,
304            });
305        };
306        if params_schema.get("type").and_then(Value::as_str) == Some("null") {
307            return Ok(ParamsType {
308                type_name: None,
309                optional: false,
310            });
311        }
312        if let Some(r) = params_schema.get("$ref").and_then(Value::as_str) {
313            return Ok(ParamsType {
314                type_name: Some(ref_name(r)),
315                optional: false,
316            });
317        }
318        if let Some(any_of) = params_schema.get("anyOf").and_then(Value::as_array)
319            && any_of.len() == 2
320        {
321            let refs: Vec<&Value> = any_of.iter().filter(|b| b.get("$ref").is_some()).collect();
322            let nulls: Vec<&Value> = any_of
323                .iter()
324                .filter(|b| b.get("type").and_then(Value::as_str) == Some("null"))
325                .collect();
326            if refs.len() == 1 && nulls.len() == 1 {
327                let r = refs[0].get("$ref").and_then(Value::as_str).unwrap();
328                return Ok(ParamsType {
329                    type_name: Some(ref_name(r)),
330                    optional: true,
331                });
332            }
333        }
334        bail!(
335            "{method}: unrecognized 'params' schema shape {params_schema} - not a plain $ref, a \
336             nullable $ref, or an explicit null. Update params_type_for to handle this shape (or \
337             the wrapper codegen in build.rs will silently emit `params: ()` for a method that \
338             actually requires typed params)."
339        );
340    }
341    bail!("{method}: not found in the given union's oneOf branches");
342}
343
344fn ref_name(r: &str) -> String {
345    r.rsplit('/').next().unwrap_or(r).to_string()
346}
347
348/// One `client_requests`/`server_requests` manifest entry (includes a
349/// resolved response type). Field order is JSON serialization order - must
350/// stay `method, variant_name, fn_name, params_type, params_optional,
351/// response_type` to byte-match the Python original's output.
352#[derive(serde::Serialize)]
353pub struct RequestEntry {
354    pub method: String,
355    pub variant_name: String,
356    pub fn_name: String,
357    pub params_type: Option<String>,
358    pub params_optional: bool,
359    pub response_type: Option<String>,
360}
361
362/// One `server_notifications`/`client_notifications` manifest entry - no
363/// `response_type` field at all (not even `null`), matching the Python
364/// original's dict literal for notifications.
365#[derive(serde::Serialize)]
366pub struct NotificationEntry {
367    pub method: String,
368    pub variant_name: String,
369    pub fn_name: String,
370    pub params_type: Option<String>,
371    pub params_optional: bool,
372}
373
374#[derive(serde::Serialize)]
375pub struct MethodsManifest {
376    pub client_requests: Vec<RequestEntry>,
377    pub server_requests: Vec<RequestEntry>,
378    pub server_notifications: Vec<NotificationEntry>,
379    pub client_notifications: Vec<NotificationEntry>,
380}
381
382/// Builds the full `methods.json` manifest from a merged, flat `definitions`
383/// map (as produced by `build_combined`).
384pub fn build_methods_manifest(combined_defs: &Map<String, Value>) -> Result<MethodsManifest> {
385    let client_request_union = get_def(combined_defs, "ClientRequest")?;
386    let server_request_union = get_def(combined_defs, "ServerRequest")?;
387    let server_notification_union = get_def(combined_defs, "ServerNotification")?;
388    let client_notification_union = get_def(combined_defs, "ClientNotification")?;
389
390    let mut client_requests = Vec::new();
391    for m in methods_of(client_request_union)? {
392        let p = params_type_for(&m, client_request_union)?;
393        let response_type = naming::resolve_response(&m, combined_defs)?;
394        client_requests.push(RequestEntry {
395            variant_name: naming::method_to_pascal(&m),
396            fn_name: naming::method_to_snake_fn(&m),
397            params_type: p.type_name,
398            params_optional: p.optional,
399            response_type,
400            method: m,
401        });
402    }
403
404    let mut server_requests = Vec::new();
405    for m in methods_of(server_request_union)? {
406        let p = params_type_for(&m, server_request_union)?;
407        let response_type = naming::resolve_response(&m, combined_defs)?;
408        server_requests.push(RequestEntry {
409            variant_name: naming::method_to_pascal(&m),
410            fn_name: naming::method_to_snake_fn(&m),
411            params_type: p.type_name,
412            params_optional: p.optional,
413            response_type,
414            method: m,
415        });
416    }
417
418    let mut server_notifications = Vec::new();
419    for m in methods_of(server_notification_union)? {
420        let p = params_type_for(&m, server_notification_union)?;
421        server_notifications.push(NotificationEntry {
422            variant_name: naming::method_to_pascal(&m),
423            fn_name: naming::method_to_snake_fn(&m),
424            params_type: p.type_name,
425            params_optional: p.optional,
426            method: m,
427        });
428    }
429
430    let mut client_notifications = Vec::new();
431    for m in methods_of(client_notification_union)? {
432        let p = params_type_for(&m, client_notification_union)?;
433        client_notifications.push(NotificationEntry {
434            variant_name: naming::method_to_pascal(&m),
435            fn_name: naming::method_to_snake_fn(&m),
436            params_type: p.type_name,
437            params_optional: p.optional,
438            method: m,
439        });
440    }
441
442    Ok(MethodsManifest {
443        client_requests,
444        server_requests,
445        server_notifications,
446        client_notifications,
447    })
448}
449
450fn get_def<'a>(defs: &'a Map<String, Value>, name: &str) -> Result<&'a Value> {
451    defs.get(name)
452        .with_context(|| format!("combined definitions missing required union type {name:?}"))
453}
454
455#[cfg(test)]
456#[path = "merge_tests.rs"]
457mod tests;