1use std::collections::BTreeSet;
7
8use anyhow::{bail, Context, Result};
9use serde_json::{json, Map, Value};
10
11use super::naming;
12
13pub 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 if let Value::String(s) = v {
22 if let Some(rest) = s.strip_prefix("#/definitions/v2/") {
23 out.insert(k.clone(), Value::String(format!("#/definitions/{rest}")));
24 continue;
25 }
26 }
27 }
28 out.insert(k.clone(), rewrite_v2_refs(v));
29 }
30 Value::Object(out)
31 }
32 Value::Array(items) => Value::Array(items.iter().map(rewrite_v2_refs).collect()),
33 other => other.clone(),
34 }
35}
36
37pub fn flatten_base_plus_oneof(schema: &Value) -> Result<Value> {
52 let Some(obj) = schema.as_object() else {
53 return Ok(schema.clone());
54 };
55 if !(obj.contains_key("oneOf") && obj.contains_key("properties")) {
56 return Ok(schema.clone());
57 }
58
59 let base_props = obj
60 .get("properties")
61 .and_then(Value::as_object)
62 .cloned()
63 .unwrap_or_default();
64 let base_required: BTreeSet<String> = obj
65 .get("required")
66 .and_then(Value::as_array)
67 .map(|a| {
68 a.iter()
69 .filter_map(|v| v.as_str().map(String::from))
70 .collect()
71 })
72 .unwrap_or_default();
73 let one_of = obj
74 .get("oneOf")
75 .and_then(Value::as_array)
76 .cloned()
77 .unwrap_or_default();
78
79 let mut flattened = Vec::with_capacity(one_of.len());
80 for (i, branch) in one_of.iter().enumerate() {
81 let branch_obj = branch.as_object().with_context(|| {
82 format!(
83 "flatten_base_plus_oneof: oneOf branch {i} is not an inline object schema ({branch}) \
84 - this workaround only knows how to flatten inline `{{\"type\": \"object\", ...}}` \
85 branches. Update flatten_base_plus_oneof to handle this shape (e.g. a $ref branch)."
86 )
87 })?;
88 if branch_obj.get("type").and_then(Value::as_str) != Some("object") {
89 bail!(
90 "flatten_base_plus_oneof: oneOf branch {i} is not \"type\": \"object\" ({branch}) - \
91 refusing to flatten a branch shape this workaround wasn't written for."
92 );
93 }
94 if !branch_obj.contains_key("properties") {
95 bail!(
96 "flatten_base_plus_oneof: oneOf branch {i} has no \"properties\" key ({branch}) - \
97 refusing to flatten, since that would silently contribute zero fields from this \
98 branch."
99 );
100 }
101
102 let mut merged_props = base_props.clone();
105 if let Some(bp) = branch_obj.get("properties").and_then(Value::as_object) {
106 for (k, v) in bp {
107 merged_props.insert(k.clone(), v.clone());
108 }
109 }
110
111 let mut merged_required = base_required.clone();
113 if let Some(br) = branch_obj.get("required").and_then(Value::as_array) {
114 merged_required.extend(br.iter().filter_map(|v| v.as_str().map(String::from)));
115 }
116
117 let mut branch_out = Map::new();
118 branch_out.insert("type".to_string(), json!("object"));
119 branch_out.insert("properties".to_string(), Value::Object(merged_props));
120 branch_out.insert(
121 "required".to_string(),
122 Value::Array(merged_required.into_iter().map(Value::String).collect()),
123 );
124 flattened.push(Value::Object(branch_out));
125 }
126
127 let mut out = Map::new();
128 out.insert(
129 "title".to_string(),
130 obj.get("title").cloned().unwrap_or(Value::Null),
131 );
132 out.insert("oneOf".to_string(), Value::Array(flattened));
133 Ok(Value::Object(out))
134}
135
136const EXPECTED_DIVERGENT_COLLISIONS: &[&str] =
143 &["ClientRequest", "ServerNotification", "RequestId"];
144
145fn check_collision_compatibility(
155 master_flat_rewritten: &Map<String, Value>,
156 v2_defs: &Map<String, Value>,
157) -> Result<()> {
158 for (name, master_def) in master_flat_rewritten {
159 let Some(v2_def) = v2_defs.get(name) else {
160 continue;
161 };
162 if master_def == v2_def {
163 continue;
164 }
165 if EXPECTED_DIVERGENT_COLLISIONS.contains(&name.as_str()) {
166 continue;
167 }
168 bail!(
169 "build_combined: definition {name:?} exists in both the master and v2 schema bundles \
170 with DIFFERENT content, and is not in EXPECTED_DIVERGENT_COLLISIONS. v2 always wins \
171 collisions, so this would silently substitute v2's copy of {name:?} into any \
172 master-derived type that references it - which may be wrong if the two copies now mean \
173 different things. Either this is a legitimate new divergence (add {name:?} to \
174 EXPECTED_DIVERGENT_COLLISIONS with a comment explaining why), or something has gone \
175 wrong upstream in how codex generates these two bundles."
176 );
177 }
178 Ok(())
179}
180
181pub fn build_combined(master: &Value, v2: &Value) -> Result<Value> {
186 let master_defs = master
187 .get("definitions")
188 .and_then(Value::as_object)
189 .context("master bundle missing top-level \"definitions\" object")?;
190 let v2_defs = v2
191 .get("definitions")
192 .and_then(Value::as_object)
193 .context("v2 bundle missing top-level \"definitions\" object")?;
194
195 let mut master_flat_rewritten: Map<String, Value> = Map::new();
196 for (k, v) in master_defs {
197 if k == "v2" {
198 continue;
199 }
200 master_flat_rewritten.insert(k.clone(), rewrite_v2_refs(v));
201 }
202
203 check_collision_compatibility(&master_flat_rewritten, v2_defs)?;
204
205 let mut combined_defs = master_flat_rewritten;
206 for (k, v) in v2_defs {
207 combined_defs.insert(k.clone(), v.clone());
214 }
215
216 if let Some(schema) = combined_defs
217 .get("McpServerElicitationRequestParams")
218 .cloned()
219 {
220 combined_defs.insert(
221 "McpServerElicitationRequestParams".to_string(),
222 flatten_base_plus_oneof(&schema)?,
223 );
224 }
225
226 Ok(wrap_definitions(combined_defs))
227}
228
229pub fn wrap_definitions(definitions: Map<String, Value>) -> Value {
233 let mut out = Map::new();
234 out.insert(
235 "$schema".to_string(),
236 json!("http://json-schema.org/draft-07/schema#"),
237 );
238 out.insert("title".to_string(), json!("CodexAppServerProtocolCombined"));
239 out.insert(
240 "description".to_string(),
241 json!(
242 "Merged, flat-ref, self-contained v2-only Codex app-server protocol schema \
243 (master envelope/ServerRequest/ClientNotification types ref-rewritten to flat \
244 + v2 client-request/notification surface). Generated by \
245 `cargo xtask codex-schema regen`."
246 ),
247 );
248 out.insert("type".to_string(), json!("object"));
249 out.insert("definitions".to_string(), Value::Object(definitions));
250 Value::Object(out)
251}
252
253pub fn methods_of(union_def: &Value) -> Result<Vec<String>> {
256 let one_of = union_def
257 .get("oneOf")
258 .and_then(Value::as_array)
259 .context("union definition missing \"oneOf\" array")?;
260 one_of
261 .iter()
262 .map(|entry| {
263 entry
264 .pointer("/properties/method/enum/0")
265 .and_then(Value::as_str)
266 .map(str::to_string)
267 .context("oneOf entry missing properties.method.enum[0]")
268 })
269 .collect()
270}
271
272#[derive(Debug)]
275pub struct ParamsType {
276 pub type_name: Option<String>,
277 pub optional: bool,
278}
279
280pub fn params_type_for(method: &str, union_def: &Value) -> Result<ParamsType> {
288 let one_of = union_def
289 .get("oneOf")
290 .and_then(Value::as_array)
291 .context("union definition missing \"oneOf\" array")?;
292
293 for entry in one_of {
294 let entry_method = entry
295 .pointer("/properties/method/enum/0")
296 .and_then(Value::as_str);
297 if entry_method != Some(method) {
298 continue;
299 }
300
301 let Some(params_schema) = entry.pointer("/properties/params") else {
302 return Ok(ParamsType {
303 type_name: None,
304 optional: false,
305 });
306 };
307 if params_schema.get("type").and_then(Value::as_str) == Some("null") {
308 return Ok(ParamsType {
309 type_name: None,
310 optional: false,
311 });
312 }
313 if let Some(r) = params_schema.get("$ref").and_then(Value::as_str) {
314 return Ok(ParamsType {
315 type_name: Some(ref_name(r)),
316 optional: false,
317 });
318 }
319 if let Some(any_of) = params_schema.get("anyOf").and_then(Value::as_array) {
320 if any_of.len() == 2 {
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 }
335 bail!(
336 "{method}: unrecognized 'params' schema shape {params_schema} - not a plain $ref, a \
337 nullable $ref, or an explicit null. Update params_type_for to handle this shape (or \
338 the wrapper codegen in build.rs will silently emit `params: ()` for a method that \
339 actually requires typed params)."
340 );
341 }
342 bail!("{method}: not found in the given union's oneOf branches");
343}
344
345fn ref_name(r: &str) -> String {
346 r.rsplit('/').next().unwrap_or(r).to_string()
347}
348
349#[derive(serde::Serialize)]
354pub struct RequestEntry {
355 pub method: String,
356 pub variant_name: String,
357 pub fn_name: String,
358 pub params_type: Option<String>,
359 pub params_optional: bool,
360 pub response_type: Option<String>,
361}
362
363#[derive(serde::Serialize)]
367pub struct NotificationEntry {
368 pub method: String,
369 pub variant_name: String,
370 pub fn_name: String,
371 pub params_type: Option<String>,
372 pub params_optional: bool,
373}
374
375#[derive(serde::Serialize)]
376pub struct MethodsManifest {
377 pub client_requests: Vec<RequestEntry>,
378 pub server_requests: Vec<RequestEntry>,
379 pub server_notifications: Vec<NotificationEntry>,
380 pub client_notifications: Vec<NotificationEntry>,
381}
382
383pub fn build_methods_manifest(combined_defs: &Map<String, Value>) -> Result<MethodsManifest> {
386 let client_request_union = get_def(combined_defs, "ClientRequest")?;
387 let server_request_union = get_def(combined_defs, "ServerRequest")?;
388 let server_notification_union = get_def(combined_defs, "ServerNotification")?;
389 let client_notification_union = get_def(combined_defs, "ClientNotification")?;
390
391 let mut client_requests = Vec::new();
392 for m in methods_of(client_request_union)? {
393 let p = params_type_for(&m, client_request_union)?;
394 let response_type = naming::resolve_response(&m, combined_defs)?;
395 client_requests.push(RequestEntry {
396 variant_name: naming::method_to_pascal(&m),
397 fn_name: naming::method_to_snake_fn(&m),
398 params_type: p.type_name,
399 params_optional: p.optional,
400 response_type,
401 method: m,
402 });
403 }
404
405 let mut server_requests = Vec::new();
406 for m in methods_of(server_request_union)? {
407 let p = params_type_for(&m, server_request_union)?;
408 let response_type = naming::resolve_response(&m, combined_defs)?;
409 server_requests.push(RequestEntry {
410 variant_name: naming::method_to_pascal(&m),
411 fn_name: naming::method_to_snake_fn(&m),
412 params_type: p.type_name,
413 params_optional: p.optional,
414 response_type,
415 method: m,
416 });
417 }
418
419 let mut server_notifications = Vec::new();
420 for m in methods_of(server_notification_union)? {
421 let p = params_type_for(&m, server_notification_union)?;
422 server_notifications.push(NotificationEntry {
423 variant_name: naming::method_to_pascal(&m),
424 fn_name: naming::method_to_snake_fn(&m),
425 params_type: p.type_name,
426 params_optional: p.optional,
427 method: m,
428 });
429 }
430
431 let mut client_notifications = Vec::new();
432 for m in methods_of(client_notification_union)? {
433 let p = params_type_for(&m, client_notification_union)?;
434 client_notifications.push(NotificationEntry {
435 variant_name: naming::method_to_pascal(&m),
436 fn_name: naming::method_to_snake_fn(&m),
437 params_type: p.type_name,
438 params_optional: p.optional,
439 method: m,
440 });
441 }
442
443 Ok(MethodsManifest {
444 client_requests,
445 server_requests,
446 server_notifications,
447 client_notifications,
448 })
449}
450
451fn get_def<'a>(defs: &'a Map<String, Value>, name: &str) -> Result<&'a Value> {
452 defs.get(name)
453 .with_context(|| format!("combined definitions missing required union type {name:?}"))
454}
455
456#[cfg(test)]
457#[path = "merge_tests.rs"]
458mod tests;