1use std::collections::BTreeMap;
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::process::Command;
22
23use anyhow::{bail, Context, Result};
24use serde::Serialize;
25use serde_json::Value;
26
27use super::{load_combined_defs, merge, CODEX_VERSION_PATH, METHODS_JSON_PATH};
28
29const USAGE: &str = "Usage: cargo xtask codex-schema drift [--dir <path-to-codex-generate-json-schema-output-dir>] [--json] [--strict]
30
31Diffs the vendored schema/methods.json + schema/CODEX_VERSION.txt against a
32fresh `codex app-server generate-json-schema --out <dir> --experimental` dump
33(or an existing dump directory passed via --dir) and reports added, removed,
34and changed methods per section (client_requests, server_requests,
35server_notifications, client_notifications).
36
37 --dir <dir> Diff against an already-generated dump directory instead of
38 shelling out to `codex` to produce a fresh one. Makes the
39 check testable/reusable in CI without requiring `codex` on
40 PATH for the dump step itself.
41 --json Emit a machine-readable report instead of the human-readable
42 one.
43 --strict Exit non-zero when drift is found. Without --strict, drift is
44 still reported loudly but the command exits 0.
45
46Missing `codex` on PATH (when --dir is not given) is never a hard failure:
47the check prints \"skipped\" and exits 0, mirroring build.rs's staleness
48check and tests/smoke.rs's live-integration test.";
49
50const SECTIONS: &[&str] = &[
53 "client_requests",
54 "server_requests",
55 "server_notifications",
56 "client_notifications",
57];
58
59pub fn run(args: &[String]) -> Result<()> {
60 let options = parse_args(args)?;
61 match compute_outcome(&options)? {
62 Outcome::Skipped(reason) => {
63 if options.json {
64 println!("{}", serde_json::json!({"skipped": true, "reason": reason}));
65 } else {
66 println!("codex-schema drift: skipped: {reason}");
67 }
68 Ok(())
69 }
70 Outcome::Report(report) => {
71 if options.json {
72 println!(
73 "{}",
74 serde_json::to_string_pretty(&report)
75 .context("serialize drift report as JSON")?
76 );
77 } else {
78 print_human_report(&report);
79 }
80 if !report.in_sync && options.strict {
81 bail!(
82 "codex-schema drift: {} method(s) drifted from the vendored schema (--strict) \
83 - review the report above, then run `cargo xtask codex-schema regen <dir>` \
84 once the change is intentional.",
85 report.drifted_count
86 );
87 }
88 Ok(())
89 }
90 }
91}
92
93#[derive(Debug)]
94struct DriftOptions {
95 dir: Option<PathBuf>,
96 json: bool,
97 strict: bool,
98}
99
100fn parse_args(args: &[String]) -> Result<DriftOptions> {
101 let mut dir = None;
102 let mut json = false;
103 let mut strict = false;
104 let mut index = 0usize;
105 while index < args.len() {
106 match args[index].as_str() {
107 "--dir" => {
108 index += 1;
109 let value = args
110 .get(index)
111 .with_context(|| format!("--dir requires a value\n\n{USAGE}"))?;
112 dir = Some(PathBuf::from(value));
113 }
114 "--json" => json = true,
115 "--strict" => strict = true,
116 "--help" | "-h" => {
117 println!("{USAGE}");
118 std::process::exit(0);
119 }
120 other => bail!("unexpected argument: {other}\n\n{USAGE}"),
121 }
122 index += 1;
123 }
124 Ok(DriftOptions { dir, json, strict })
125}
126
127enum Outcome {
128 Skipped(String),
129 Report(DriftReport),
130}
131
132fn compute_outcome(options: &DriftOptions) -> Result<Outcome> {
137 let vendored_version = load_vendored_version()?;
138 let vendored_manifest = load_vendored_manifest()?;
139
140 let (gen_dir, installed_version, _dump_guard) = match &options.dir {
141 Some(dir) => {
142 if !dir.exists() {
143 bail!(
144 "--dir {} does not exist - generate it first with:\n \
145 codex app-server generate-json-schema --out {} --experimental",
146 dir.display(),
147 dir.display()
148 );
149 }
150 (dir.clone(), installed_version_best_effort(), None)
151 }
152 None => {
153 let Some(installed_version) = codex_version_or_skip()? else {
154 return Ok(Outcome::Skipped(
155 "no codex on PATH - cannot generate a fresh schema dump to diff against. \
156 Install the codex CLI, or pass --dir <existing-dump> to diff a saved \
157 `codex app-server generate-json-schema` output."
158 .to_string(),
159 ));
160 };
161 let dump =
162 tempfile::tempdir().context("create temp dir for a fresh codex schema dump")?;
163 generate_fresh_dump(dump.path())?;
164 let gen_dir = dump.path().to_path_buf();
165 (gen_dir, Some(installed_version), Some(dump))
166 }
167 };
168
169 let installed_manifest = build_installed_manifest(&gen_dir)?;
170 let report = build_report(
171 &vendored_manifest,
172 &installed_manifest,
173 &vendored_version,
174 installed_version.as_deref(),
175 )?;
176 Ok(Outcome::Report(report))
177}
178
179fn codex_version_or_skip() -> Result<Option<String>> {
183 match Command::new("codex").arg("--version").output() {
184 Ok(output) if output.status.success() => {
185 let raw = String::from_utf8(output.stdout)
186 .context("`codex --version` emitted non-UTF-8 output")?;
187 let trimmed = raw.trim();
188 if trimmed.is_empty() {
189 bail!("`codex --version` produced no output");
190 }
191 Ok(Some(trimmed.to_string()))
192 }
193 Ok(output) => bail!(
194 "`codex --version` exited with status {}: {}",
195 output.status,
196 String::from_utf8_lossy(&output.stderr)
197 ),
198 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
199 Err(err) => Err(err).context("failed to run `codex --version`"),
200 }
201}
202
203fn installed_version_best_effort() -> Option<String> {
208 let output = Command::new("codex").arg("--version").output().ok()?;
209 if !output.status.success() {
210 return None;
211 }
212 let raw = String::from_utf8(output.stdout).ok()?;
213 let trimmed = raw.trim();
214 (!trimmed.is_empty()).then(|| trimmed.to_string())
215}
216
217fn generate_fresh_dump(out_dir: &Path) -> Result<()> {
220 let status = Command::new("codex")
221 .arg("app-server")
222 .arg("generate-json-schema")
223 .arg("--out")
224 .arg(out_dir)
225 .arg("--experimental")
226 .status()
227 .context("failed to run `codex app-server generate-json-schema`")?;
228 if !status.success() {
229 bail!(
230 "`codex app-server generate-json-schema --out {} --experimental` exited with status {status}",
231 out_dir.display()
232 );
233 }
234 Ok(())
235}
236
237fn load_vendored_manifest() -> Result<Value> {
238 super::read_json(Path::new(METHODS_JSON_PATH))
239}
240
241fn load_vendored_version() -> Result<String> {
242 let text = fs::read_to_string(CODEX_VERSION_PATH)
243 .with_context(|| format!("read {CODEX_VERSION_PATH}"))?;
244 let trimmed = text.trim();
245 if trimmed.is_empty() {
246 bail!("{CODEX_VERSION_PATH} is empty");
247 }
248 Ok(trimmed.to_string())
249}
250
251fn build_installed_manifest(gen_dir: &Path) -> Result<Value> {
257 let (_, combined_defs) = load_combined_defs(gen_dir)?;
258 let manifest = merge::build_methods_manifest(&combined_defs)?;
259 serde_json::to_value(&manifest)
260 .context("serialize installed methods manifest to JSON for diffing")
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
269struct MethodSignature {
270 params_type: Option<String>,
271 params_optional: bool,
272 response_type: Option<String>,
273}
274
275fn section_map(manifest: &Value, section: &str) -> Result<BTreeMap<String, MethodSignature>> {
282 let entries = manifest
283 .get(section)
284 .and_then(Value::as_array)
285 .with_context(|| format!("manifest missing \"{section}\" array"))?;
286
287 let mut map = BTreeMap::new();
288 for (index, entry) in entries.iter().enumerate() {
289 let method = entry
290 .get("method")
291 .and_then(Value::as_str)
292 .with_context(|| format!("{section}[{index}] is missing a string \"method\" field"))?
293 .to_string();
294 let signature = MethodSignature {
295 params_type: entry
296 .get("params_type")
297 .and_then(Value::as_str)
298 .map(str::to_string),
299 params_optional: entry
300 .get("params_optional")
301 .and_then(Value::as_bool)
302 .unwrap_or(false),
303 response_type: entry
304 .get("response_type")
305 .and_then(Value::as_str)
306 .map(str::to_string),
307 };
308 if map.insert(method.clone(), signature).is_some() {
309 bail!("{section}: duplicate method {method:?} in manifest - malformed input");
310 }
311 }
312 Ok(map)
313}
314
315#[derive(Debug, Clone, Serialize)]
316struct MethodEntry {
317 method: String,
318 params_type: Option<String>,
319 params_optional: bool,
320 response_type: Option<String>,
321}
322
323impl MethodEntry {
324 fn new(method: &str, signature: &MethodSignature) -> Self {
325 Self {
326 method: method.to_string(),
327 params_type: signature.params_type.clone(),
328 params_optional: signature.params_optional,
329 response_type: signature.response_type.clone(),
330 }
331 }
332}
333
334#[derive(Debug, Clone, Serialize)]
335struct ChangedMethod {
336 method: String,
337 vendored: MethodSignature,
338 installed: MethodSignature,
339}
340
341#[derive(Debug, Clone, Serialize)]
342struct SectionDiff {
343 section: String,
344 vendored_count: usize,
345 installed_count: usize,
346 added: Vec<MethodEntry>,
347 removed: Vec<MethodEntry>,
348 changed: Vec<ChangedMethod>,
349}
350
351impl SectionDiff {
352 fn drifted_count(&self) -> usize {
353 self.added.len() + self.removed.len() + self.changed.len()
354 }
355
356 fn in_sync(&self) -> bool {
357 self.drifted_count() == 0
358 }
359}
360
361fn diff_section(section: &str, vendored: &Value, installed: &Value) -> Result<SectionDiff> {
369 let vendored_map = section_map(vendored, section)?;
370 let installed_map = section_map(installed, section)?;
371
372 let mut added = Vec::new();
373 let mut changed = Vec::new();
374 for (method, installed_sig) in &installed_map {
375 match vendored_map.get(method) {
376 None => added.push(MethodEntry::new(method, installed_sig)),
377 Some(vendored_sig) if vendored_sig != installed_sig => changed.push(ChangedMethod {
378 method: method.clone(),
379 vendored: vendored_sig.clone(),
380 installed: installed_sig.clone(),
381 }),
382 Some(_) => {}
383 }
384 }
385
386 let mut removed = Vec::new();
387 for (method, vendored_sig) in &vendored_map {
388 if !installed_map.contains_key(method) {
389 removed.push(MethodEntry::new(method, vendored_sig));
390 }
391 }
392
393 added.sort_by(|a, b| a.method.cmp(&b.method));
394 removed.sort_by(|a, b| a.method.cmp(&b.method));
395 changed.sort_by(|a, b| a.method.cmp(&b.method));
396
397 Ok(SectionDiff {
398 section: section.to_string(),
399 vendored_count: vendored_map.len(),
400 installed_count: installed_map.len(),
401 added,
402 removed,
403 changed,
404 })
405}
406
407#[derive(Debug, Clone, Serialize)]
408struct VersionDelta {
409 vendored: String,
410 installed: Option<String>,
411 matches: Option<bool>,
415}
416
417#[derive(Debug, Clone, Serialize)]
418struct DriftReport {
419 in_sync: bool,
425 drifted_count: usize,
426 version: VersionDelta,
427 sections: Vec<SectionDiff>,
428}
429
430fn build_report(
431 vendored_manifest: &Value,
432 installed_manifest: &Value,
433 vendored_version: &str,
434 installed_version: Option<&str>,
435) -> Result<DriftReport> {
436 let sections = SECTIONS
437 .iter()
438 .map(|section| diff_section(section, vendored_manifest, installed_manifest))
439 .collect::<Result<Vec<_>>>()?;
440 let drifted_count = sections.iter().map(SectionDiff::drifted_count).sum();
441 let version = VersionDelta {
442 vendored: vendored_version.to_string(),
443 installed: installed_version.map(str::to_string),
444 matches: installed_version.map(|installed| installed == vendored_version),
445 };
446 Ok(DriftReport {
447 in_sync: drifted_count == 0,
448 drifted_count,
449 version,
450 sections,
451 })
452}
453
454fn print_human_report(report: &DriftReport) {
455 if report.in_sync {
456 println!("codex-schema drift: in sync");
457 } else {
458 println!(
459 "codex-schema drift: {} method(s) drifted from the vendored schema",
460 report.drifted_count
461 );
462 }
463
464 match (&report.version.installed, report.version.matches) {
465 (Some(installed), Some(true)) => {
466 println!(" codex version: {installed} (matches vendored)");
467 }
468 (Some(installed), Some(false)) => {
469 println!(
470 " codex version: vendored={} installed={} (MISMATCH)",
471 report.version.vendored, installed
472 );
473 }
474 _ => {
475 println!(
476 " codex version: vendored={} installed=unknown (codex --version unavailable)",
477 report.version.vendored
478 );
479 }
480 }
481 println!();
482
483 for section in &report.sections {
484 if section.in_sync() {
485 println!(
486 "{}: {} -> {} (in sync)",
487 section.section, section.vendored_count, section.installed_count
488 );
489 continue;
490 }
491 println!(
492 "{}: {} -> {} (+{} added, -{} removed, ~{} changed)",
493 section.section,
494 section.vendored_count,
495 section.installed_count,
496 section.added.len(),
497 section.removed.len(),
498 section.changed.len()
499 );
500 for entry in §ion.added {
501 println!(
502 " + {} (params={:?} optional={} response={:?})",
503 entry.method, entry.params_type, entry.params_optional, entry.response_type
504 );
505 }
506 for entry in §ion.removed {
507 println!(
508 " - {} (params={:?} optional={} response={:?})",
509 entry.method, entry.params_type, entry.params_optional, entry.response_type
510 );
511 }
512 for changed in §ion.changed {
513 println!(" ~ {}", changed.method);
514 if changed.vendored.params_type != changed.installed.params_type {
515 println!(
516 " params_type: {:?} -> {:?}",
517 changed.vendored.params_type, changed.installed.params_type
518 );
519 }
520 if changed.vendored.params_optional != changed.installed.params_optional {
521 println!(
522 " params_optional: {} -> {}",
523 changed.vendored.params_optional, changed.installed.params_optional
524 );
525 }
526 if changed.vendored.response_type != changed.installed.response_type {
527 println!(
528 " response_type: {:?} -> {:?}",
529 changed.vendored.response_type, changed.installed.response_type
530 );
531 }
532 }
533 }
534
535 println!();
536 if report.in_sync {
537 println!("no remediation needed.");
538 } else {
539 println!(
540 "remediation: cargo xtask codex-schema regen <path-to-codex-generate-json-schema-output-dir>"
541 );
542 }
543}
544
545#[cfg(test)]
546#[path = "drift_tests.rs"]
547mod tests;