1use std::collections::BTreeSet;
7
8use anyhow::{Context, Result, bail};
9use serde_json::{Map, Value, json};
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 && 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
36pub 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 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 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
135const EXPECTED_DIVERGENT_COLLISIONS: &[&str] =
142 &["ClientRequest", "ServerNotification", "RequestId"];
143
144fn 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
180pub 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 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
228pub 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
252pub 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#[derive(Debug)]
274pub struct ParamsType {
275 pub type_name: Option<String>,
276 pub optional: bool,
277}
278
279pub 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#[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#[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
382pub 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;