Skip to main content

xtask/codex_schema/
regen.rs

1//! `cargo xtask codex-schema regen <dir>` - rebuilds the vendored schema
2//! assets from a fresh `codex app-server generate-json-schema` dump, and
3//! stamps the `codex` version they were generated from for staleness
4//! detection (see `crates/shared/codex-app-server-client/build.rs`).
5
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use anyhow::{bail, Context, Result};
11
12use super::{
13    load_combined_defs, merge, parse_gen_dir, CODEX_VERSION_PATH, METHODS_JSON_PATH,
14    PROTOCOL_SCHEMA_PATH,
15};
16
17pub fn run(args: &[String]) -> Result<()> {
18    let gen_dir = parse_args(args)?;
19    regen(&gen_dir)
20}
21
22fn parse_args(args: &[String]) -> Result<PathBuf> {
23    parse_gen_dir(
24        args,
25        "Usage: cargo xtask codex-schema regen <path-to-codex-generate-json-schema-output-dir>\n\
26         Generate that directory first with:\n  \
27         codex app-server generate-json-schema --out <dir> --experimental",
28    )
29}
30
31pub fn regen(gen_dir: &Path) -> Result<()> {
32    // Captured (and its file written) first, before either schema file is
33    // touched: `codex --version` failing (e.g. not on PATH) is the one step
34    // in this function most likely to fail on a maintainer's machine, and
35    // doing it last used to leave the working tree in a confusing
36    // partially-regenerated state (both schema files rewritten, but the
37    // version stamp untouched) if it failed. Front-loading it means a
38    // failure here leaves nothing changed at all.
39    let version = capture_codex_version()?;
40
41    let (combined, combined_defs) = load_combined_defs(gen_dir)?;
42
43    let protocol_text =
44        serde_json::to_string_pretty(&combined).context("serialize combined schema")?;
45    assert_no_v2_refs(&protocol_text)?;
46
47    let manifest = merge::build_methods_manifest(&combined_defs)?;
48    // Serializes the struct directly to a string rather than going through an
49    // intermediate `serde_json::Value` first: `RequestEntry`/`NotificationEntry`
50    // derive `Serialize`, so a struct's field order is always its declaration
51    // order regardless of `serde_json::Map`'s own key-ordering behavior - but
52    // routing through `to_value()` first would lose that guarantee, since the
53    // resulting `Value::Object`'s `Map` re-sorts keys unless the crate-wide
54    // (and workspace-unifying) `preserve_order` feature is enabled.
55    let methods_text =
56        serde_json::to_string_pretty(&manifest).context("serialize methods manifest")?;
57
58    fs::write(PROTOCOL_SCHEMA_PATH, &protocol_text)
59        .with_context(|| format!("write {PROTOCOL_SCHEMA_PATH}"))?;
60    fs::write(METHODS_JSON_PATH, &methods_text)
61        .with_context(|| format!("write {METHODS_JSON_PATH}"))?;
62
63    eprintln!("total definitions: {}", combined_defs.len());
64    eprintln!("wrote {PROTOCOL_SCHEMA_PATH}");
65
66    let missing_response: Vec<&str> = manifest
67        .client_requests
68        .iter()
69        .chain(&manifest.server_requests)
70        .filter(|e| e.response_type.is_none())
71        .map(|e| e.method.as_str())
72        .collect();
73    eprintln!(
74        "client_requests={} server_requests={} server_notifications={} client_notifications={}",
75        manifest.client_requests.len(),
76        manifest.server_requests.len(),
77        manifest.server_notifications.len(),
78        manifest.client_notifications.len(),
79    );
80    eprintln!("methods with no resolvable response type: {missing_response:?}");
81    eprintln!("wrote {METHODS_JSON_PATH}");
82
83    stamp_codex_version(&version)?;
84
85    Ok(())
86}
87
88/// Mirrors the Python original's final sanity assertion: every
89/// `#/definitions/v2/X` ref must have been rewritten to `#/definitions/X`
90/// before writing - a leftover match means the ref-rewrite pass missed
91/// something and the output would fail to resolve.
92fn assert_no_v2_refs(serialized_protocol_schema: &str) -> Result<()> {
93    let count = serialized_protocol_schema
94        .matches("#/definitions/v2/")
95        .count();
96    eprintln!("remaining v2-prefixed refs (must be 0): {count}");
97    if count != 0 {
98        bail!("ref rewrite incomplete: found {count} remaining \"#/definitions/v2/\" ref(s)");
99    }
100    Ok(())
101}
102
103fn stamp_codex_version(version: &str) -> Result<()> {
104    fs::write(CODEX_VERSION_PATH, format!("{version}\n"))
105        .with_context(|| format!("write {CODEX_VERSION_PATH}"))?;
106    eprintln!("stamped {CODEX_VERSION_PATH}: {version:?}");
107    Ok(())
108}
109
110fn capture_codex_version() -> Result<String> {
111    let output = Command::new("codex")
112        .arg("--version")
113        .output()
114        .context("failed to run `codex --version` - is the codex CLI installed and on PATH?")?;
115    if !output.status.success() {
116        bail!(
117            "`codex --version` exited with status {}: {}",
118            output.status,
119            String::from_utf8_lossy(&output.stderr)
120        );
121    }
122    let raw =
123        String::from_utf8(output.stdout).context("`codex --version` emitted non-UTF-8 output")?;
124    let trimmed = raw.trim();
125    if trimmed.is_empty() {
126        bail!("`codex --version` produced no output");
127    }
128    Ok(trimmed.to_string())
129}