1use crate::ClientNotification;
2use crate::ClientRequest;
3use crate::ServerNotification;
4use crate::ServerRequest;
5use crate::export_client_notification_schemas;
6use crate::export_client_param_schemas;
7use crate::export_client_response_schemas;
8use crate::export_client_responses;
9use crate::export_server_notification_schemas;
10use crate::export_server_param_schemas;
11use crate::export_server_response_schemas;
12use crate::export_server_responses;
13use anyhow::Context;
14use anyhow::Result;
15use anyhow::anyhow;
16use codex_protocol::protocol::EventMsg;
17use schemars::JsonSchema;
18use schemars::schema_for;
19use serde::Serialize;
20use serde_json::Map;
21use serde_json::Value;
22use std::collections::HashMap;
23use std::collections::HashSet;
24use std::ffi::OsStr;
25use std::fs;
26use std::io::Read;
27use std::io::Write;
28use std::path::Path;
29use std::path::PathBuf;
30use std::process::Command;
31use ts_rs::TS;
32
33const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n";
34
35#[derive(Clone)]
36pub struct GeneratedSchema {
37 namespace: Option<String>,
38 logical_name: String,
39 value: Value,
40 in_v1_dir: bool,
41}
42
43impl GeneratedSchema {
44 fn namespace(&self) -> Option<&str> {
45 self.namespace.as_deref()
46 }
47
48 fn logical_name(&self) -> &str {
49 &self.logical_name
50 }
51
52 fn value(&self) -> &Value {
53 &self.value
54 }
55}
56
57type JsonSchemaEmitter = fn(&Path) -> Result<GeneratedSchema>;
58pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
59 generate_ts(out_dir, prettier)?;
60 generate_json(out_dir)?;
61 Ok(())
62}
63
64#[derive(Clone, Copy, Debug)]
65pub struct GenerateTsOptions {
66 pub generate_indices: bool,
67 pub ensure_headers: bool,
68 pub run_prettier: bool,
69}
70
71impl Default for GenerateTsOptions {
72 fn default() -> Self {
73 Self {
74 generate_indices: true,
75 ensure_headers: true,
76 run_prettier: true,
77 }
78 }
79}
80
81pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
82 generate_ts_with_options(out_dir, prettier, GenerateTsOptions::default())
83}
84
85pub fn generate_ts_with_options(
86 out_dir: &Path,
87 prettier: Option<&Path>,
88 options: GenerateTsOptions,
89) -> Result<()> {
90 let v2_out_dir = out_dir.join("v2");
91 ensure_dir(out_dir)?;
92 ensure_dir(&v2_out_dir)?;
93
94 ClientRequest::export_all_to(out_dir)?;
95 export_client_responses(out_dir)?;
96 ClientNotification::export_all_to(out_dir)?;
97
98 ServerRequest::export_all_to(out_dir)?;
99 export_server_responses(out_dir)?;
100 ServerNotification::export_all_to(out_dir)?;
101
102 if options.generate_indices {
103 generate_index_ts(out_dir)?;
104 generate_index_ts(&v2_out_dir)?;
105 }
106
107 let mut ts_files = Vec::new();
109 let should_collect_ts_files =
110 options.ensure_headers || (options.run_prettier && prettier.is_some());
111 if should_collect_ts_files {
112 ts_files = ts_files_in_recursive(out_dir)?;
113 }
114
115 if options.ensure_headers {
116 for file in &ts_files {
117 prepend_header_if_missing(file)?;
118 }
119 }
120
121 if options.run_prettier
123 && let Some(prettier_bin) = prettier
124 && !ts_files.is_empty()
125 {
126 let status = Command::new(prettier_bin)
127 .arg("--write")
128 .arg("--log-level")
129 .arg("warn")
130 .args(ts_files.iter().map(|p| p.as_os_str()))
131 .status()
132 .with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?;
133 if !status.success() {
134 return Err(anyhow!("Prettier failed with status {status}"));
135 }
136 }
137
138 Ok(())
139}
140
141pub fn generate_json(out_dir: &Path) -> Result<()> {
142 ensure_dir(out_dir)?;
143 let envelope_emitters: &[JsonSchemaEmitter] = &[
144 |d| write_json_schema_with_return::<crate::RequestId>(d, "RequestId"),
145 |d| write_json_schema_with_return::<crate::JSONRPCMessage>(d, "JSONRPCMessage"),
146 |d| write_json_schema_with_return::<crate::JSONRPCRequest>(d, "JSONRPCRequest"),
147 |d| write_json_schema_with_return::<crate::JSONRPCNotification>(d, "JSONRPCNotification"),
148 |d| write_json_schema_with_return::<crate::JSONRPCResponse>(d, "JSONRPCResponse"),
149 |d| write_json_schema_with_return::<crate::JSONRPCError>(d, "JSONRPCError"),
150 |d| write_json_schema_with_return::<crate::JSONRPCErrorError>(d, "JSONRPCErrorError"),
151 |d| write_json_schema_with_return::<crate::ClientRequest>(d, "ClientRequest"),
152 |d| write_json_schema_with_return::<crate::ServerRequest>(d, "ServerRequest"),
153 |d| write_json_schema_with_return::<crate::ClientNotification>(d, "ClientNotification"),
154 |d| write_json_schema_with_return::<crate::ServerNotification>(d, "ServerNotification"),
155 |d| write_json_schema_with_return::<EventMsg>(d, "EventMsg"),
156 ];
157
158 let mut schemas: Vec<GeneratedSchema> = Vec::new();
159 for emit in envelope_emitters {
160 schemas.push(emit(out_dir)?);
161 }
162
163 schemas.extend(export_client_param_schemas(out_dir)?);
164 schemas.extend(export_client_response_schemas(out_dir)?);
165 schemas.extend(export_server_param_schemas(out_dir)?);
166 schemas.extend(export_server_response_schemas(out_dir)?);
167 schemas.extend(export_client_notification_schemas(out_dir)?);
168 schemas.extend(export_server_notification_schemas(out_dir)?);
169
170 let bundle = build_schema_bundle(schemas)?;
171 write_pretty_json(
172 out_dir.join("codex_app_server_protocol.schemas.json"),
173 &bundle,
174 )?;
175
176 Ok(())
177}
178
179fn build_schema_bundle(schemas: Vec<GeneratedSchema>) -> Result<Value> {
180 const SPECIAL_DEFINITIONS: &[&str] = &[
181 "ClientNotification",
182 "ClientRequest",
183 "EventMsg",
184 "ServerNotification",
185 "ServerRequest",
186 ];
187 const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"];
188
189 let namespaced_types = collect_namespaced_types(&schemas);
190 let mut definitions = Map::new();
191
192 for schema in schemas {
193 let GeneratedSchema {
194 namespace,
195 logical_name,
196 mut value,
197 in_v1_dir,
198 } = schema;
199
200 if IGNORED_DEFINITIONS.contains(&logical_name.as_str()) {
201 continue;
202 }
203
204 if let Some(ref ns) = namespace {
205 rewrite_refs_to_namespace(&mut value, ns);
206 }
207
208 let mut forced_namespace_refs: Vec<(String, String)> = Vec::new();
209 if let Value::Object(ref mut obj) = value
210 && let Some(defs) = obj.remove("definitions")
211 && let Value::Object(defs_obj) = defs
212 {
213 for (def_name, mut def_schema) in defs_obj {
214 if IGNORED_DEFINITIONS.contains(&def_name.as_str()) {
215 continue;
216 }
217 if SPECIAL_DEFINITIONS.contains(&def_name.as_str()) {
218 continue;
219 }
220 annotate_schema(&mut def_schema, Some(def_name.as_str()));
221 let target_namespace = match namespace {
222 Some(ref ns) => Some(ns.clone()),
223 None => namespace_for_definition(&def_name, &namespaced_types)
224 .cloned()
225 .filter(|_| !in_v1_dir),
226 };
227 if let Some(ref ns) = target_namespace {
228 if namespace.as_deref() == Some(ns.as_str()) {
229 rewrite_refs_to_namespace(&mut def_schema, ns);
230 insert_into_namespace(&mut definitions, ns, def_name.clone(), def_schema)?;
231 } else if !forced_namespace_refs
232 .iter()
233 .any(|(name, existing_ns)| name == &def_name && existing_ns == ns)
234 {
235 forced_namespace_refs.push((def_name.clone(), ns.clone()));
236 }
237 } else {
238 definitions.insert(def_name, def_schema);
239 }
240 }
241 }
242
243 for (name, ns) in forced_namespace_refs {
244 rewrite_named_ref_to_namespace(&mut value, &ns, &name);
245 }
246
247 if let Some(ref ns) = namespace {
248 insert_into_namespace(&mut definitions, ns, logical_name.clone(), value)?;
249 } else {
250 definitions.insert(logical_name, value);
251 }
252 }
253
254 let mut root = Map::new();
255 root.insert(
256 "$schema".to_string(),
257 Value::String("http://json-schema.org/draft-07/schema#".into()),
258 );
259 root.insert(
260 "title".to_string(),
261 Value::String("CodexAppServerProtocol".into()),
262 );
263 root.insert("type".to_string(), Value::String("object".into()));
264 root.insert("definitions".to_string(), Value::Object(definitions));
265
266 Ok(Value::Object(root))
267}
268
269fn insert_into_namespace(
270 definitions: &mut Map<String, Value>,
271 namespace: &str,
272 name: String,
273 schema: Value,
274) -> Result<()> {
275 let entry = definitions
276 .entry(namespace.to_string())
277 .or_insert_with(|| Value::Object(Map::new()));
278 match entry {
279 Value::Object(map) => {
280 map.insert(name, schema);
281 Ok(())
282 }
283 _ => Err(anyhow!("expected namespace {namespace} to be an object")),
284 }
285}
286
287fn write_json_schema_with_return<T>(out_dir: &Path, name: &str) -> Result<GeneratedSchema>
288where
289 T: JsonSchema,
290{
291 let file_stem = name.trim();
292 let schema = schema_for!(T);
293 let mut schema_value = serde_json::to_value(schema)?;
294 annotate_schema(&mut schema_value, Some(file_stem));
295 let (raw_namespace, logical_name) = split_namespace(file_stem);
299 let out_path = if let Some(ns) = raw_namespace {
300 let dir = out_dir.join(ns);
301 ensure_dir(&dir)?;
302 dir.join(format!("{logical_name}.json"))
303 } else {
304 out_dir.join(format!("{file_stem}.json"))
305 };
306
307 write_pretty_json(out_path, &schema_value)
308 .with_context(|| format!("Failed to write JSON schema for {file_stem}"))?;
309 let namespace = match raw_namespace {
310 Some("v1") | None => None,
311 Some(ns) => Some(ns.to_string()),
312 };
313 Ok(GeneratedSchema {
314 in_v1_dir: raw_namespace == Some("v1"),
315 namespace,
316 logical_name: logical_name.to_string(),
317 value: schema_value,
318 })
319}
320
321pub(crate) fn write_json_schema<T>(out_dir: &Path, name: &str) -> Result<GeneratedSchema>
322where
323 T: JsonSchema,
324{
325 write_json_schema_with_return::<T>(out_dir, name)
326}
327
328fn write_pretty_json(path: PathBuf, value: &impl Serialize) -> Result<()> {
329 let json = serde_json::to_vec_pretty(value)
330 .with_context(|| format!("Failed to serialize JSON schema to {}", path.display()))?;
331 fs::write(&path, json).with_context(|| format!("Failed to write {}", path.display()))?;
332 Ok(())
333}
334
335fn split_namespace(name: &str) -> (Option<&str>, &str) {
337 name.split_once("::")
338 .map_or((None, name), |(ns, rest)| (Some(ns), rest))
339}
340
341fn rewrite_refs_to_namespace(value: &mut Value, ns: &str) {
344 match value {
345 Value::Object(obj) => {
346 if let Some(Value::String(r)) = obj.get_mut("$ref")
347 && let Some(suffix) = r.strip_prefix("#/definitions/")
348 {
349 let prefix = format!("{ns}/");
350 if !suffix.starts_with(&prefix) {
351 *r = format!("#/definitions/{ns}/{suffix}");
352 }
353 }
354 for v in obj.values_mut() {
355 rewrite_refs_to_namespace(v, ns);
356 }
357 }
358 Value::Array(items) => {
359 for v in items.iter_mut() {
360 rewrite_refs_to_namespace(v, ns);
361 }
362 }
363 _ => {}
364 }
365}
366
367fn collect_namespaced_types(schemas: &[GeneratedSchema]) -> HashMap<String, String> {
368 let mut types = HashMap::new();
369 for schema in schemas {
370 if let Some(ns) = schema.namespace() {
371 types
372 .entry(schema.logical_name().to_string())
373 .or_insert_with(|| ns.to_string());
374 if let Some(Value::Object(defs)) = schema.value().get("definitions") {
375 for key in defs.keys() {
376 types.entry(key.clone()).or_insert_with(|| ns.to_string());
377 }
378 }
379 if let Some(Value::Object(defs)) = schema.value().get("$defs") {
380 for key in defs.keys() {
381 types.entry(key.clone()).or_insert_with(|| ns.to_string());
382 }
383 }
384 }
385 }
386 types
387}
388
389fn namespace_for_definition<'a>(
390 name: &str,
391 types: &'a HashMap<String, String>,
392) -> Option<&'a String> {
393 if let Some(ns) = types.get(name) {
394 return Some(ns);
395 }
396 let trimmed = name.trim_end_matches(|c: char| c.is_ascii_digit());
397 if trimmed != name {
398 return types.get(trimmed);
399 }
400 None
401}
402
403fn variant_definition_name(base: &str, variant: &Value) -> Option<String> {
404 if let Some(props) = variant.get("properties").and_then(Value::as_object) {
405 if let Some(method_literal) = literal_from_property(props, "method") {
406 let pascal = to_pascal_case(method_literal);
407 return Some(match base {
408 "ClientRequest" | "ServerRequest" => format!("{pascal}Request"),
409 "ClientNotification" | "ServerNotification" => format!("{pascal}Notification"),
410 _ => format!("{pascal}{base}"),
411 });
412 }
413
414 if let Some(type_literal) = literal_from_property(props, "type") {
415 let pascal = to_pascal_case(type_literal);
416 return Some(match base {
417 "EventMsg" => format!("{pascal}EventMsg"),
418 _ => format!("{pascal}{base}"),
419 });
420 }
421
422 if props.len() == 1
423 && let Some(key) = props.keys().next()
424 {
425 let pascal = to_pascal_case(key);
426 return Some(format!("{pascal}{base}"));
427 }
428 }
429
430 if let Some(required) = variant.get("required").and_then(Value::as_array)
431 && required.len() == 1
432 && let Some(key) = required[0].as_str()
433 {
434 let pascal = to_pascal_case(key);
435 return Some(format!("{pascal}{base}"));
436 }
437
438 None
439}
440
441fn literal_from_property<'a>(props: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
442 props.get(key).and_then(string_literal)
443}
444
445fn string_literal(value: &Value) -> Option<&str> {
446 value.get("const").and_then(Value::as_str).or_else(|| {
447 value
448 .get("enum")
449 .and_then(Value::as_array)
450 .and_then(|arr| arr.first())
451 .and_then(Value::as_str)
452 })
453}
454
455fn annotate_schema(value: &mut Value, base: Option<&str>) {
456 match value {
457 Value::Object(map) => annotate_object(map, base),
458 Value::Array(items) => {
459 for item in items {
460 annotate_schema(item, base);
461 }
462 }
463 _ => {}
464 }
465}
466
467fn annotate_object(map: &mut Map<String, Value>, base: Option<&str>) {
468 let owner = map.get("title").and_then(Value::as_str).map(str::to_owned);
469 if let Some(owner) = owner.as_deref()
470 && let Some(Value::Object(props)) = map.get_mut("properties")
471 {
472 set_discriminator_titles(props, owner);
473 }
474
475 if let Some(Value::Array(variants)) = map.get_mut("oneOf") {
476 annotate_variant_list(variants, base);
477 }
478 if let Some(Value::Array(variants)) = map.get_mut("anyOf") {
479 annotate_variant_list(variants, base);
480 }
481
482 if let Some(Value::Object(defs)) = map.get_mut("definitions") {
483 for (name, schema) in defs.iter_mut() {
484 annotate_schema(schema, Some(name.as_str()));
485 }
486 }
487
488 if let Some(Value::Object(defs)) = map.get_mut("$defs") {
489 for (name, schema) in defs.iter_mut() {
490 annotate_schema(schema, Some(name.as_str()));
491 }
492 }
493
494 if let Some(Value::Object(props)) = map.get_mut("properties") {
495 for value in props.values_mut() {
496 annotate_schema(value, base);
497 }
498 }
499
500 if let Some(items) = map.get_mut("items") {
501 annotate_schema(items, base);
502 }
503
504 if let Some(additional) = map.get_mut("additionalProperties") {
505 annotate_schema(additional, base);
506 }
507
508 for (key, child) in map.iter_mut() {
509 match key.as_str() {
510 "oneOf"
511 | "anyOf"
512 | "definitions"
513 | "$defs"
514 | "properties"
515 | "items"
516 | "additionalProperties" => {}
517 _ => annotate_schema(child, base),
518 }
519 }
520}
521
522fn annotate_variant_list(variants: &mut [Value], base: Option<&str>) {
523 let mut seen = HashSet::new();
524
525 for variant in variants.iter() {
526 if let Some(name) = variant_title(variant) {
527 seen.insert(name.to_owned());
528 }
529 }
530
531 for variant in variants.iter_mut() {
532 let mut variant_name = variant_title(variant).map(str::to_owned);
533
534 if variant_name.is_none()
535 && let Some(base_name) = base
536 && let Some(name) = variant_definition_name(base_name, variant)
537 {
538 let mut candidate = name.clone();
539 let mut index = 2;
540 while seen.contains(&candidate) {
541 candidate = format!("{name}{index}");
542 index += 1;
543 }
544 if let Some(obj) = variant.as_object_mut() {
545 obj.insert("title".into(), Value::String(candidate.clone()));
546 }
547 seen.insert(candidate.clone());
548 variant_name = Some(candidate);
549 }
550
551 if let Some(name) = variant_name.as_deref()
552 && let Some(obj) = variant.as_object_mut()
553 && let Some(Value::Object(props)) = obj.get_mut("properties")
554 {
555 set_discriminator_titles(props, name);
556 }
557
558 annotate_schema(variant, base);
559 }
560}
561
562const DISCRIMINATOR_KEYS: &[&str] = &["type", "method", "mode", "status", "role", "reason"];
563
564fn set_discriminator_titles(props: &mut Map<String, Value>, owner: &str) {
565 for key in DISCRIMINATOR_KEYS {
566 if let Some(prop_schema) = props.get_mut(*key)
567 && string_literal(prop_schema).is_some()
568 && let Value::Object(prop_obj) = prop_schema
569 {
570 if prop_obj.contains_key("title") {
571 continue;
572 }
573 let suffix = to_pascal_case(key);
574 prop_obj.insert("title".into(), Value::String(format!("{owner}{suffix}")));
575 }
576 }
577}
578
579fn variant_title(value: &Value) -> Option<&str> {
580 value
581 .as_object()
582 .and_then(|obj| obj.get("title"))
583 .and_then(Value::as_str)
584}
585
586fn to_pascal_case(input: &str) -> String {
587 let mut result = String::new();
588 let mut capitalize_next = true;
589
590 for c in input.chars() {
591 if c == '_' || c == '-' {
592 capitalize_next = true;
593 continue;
594 }
595
596 if capitalize_next {
597 result.extend(c.to_uppercase());
598 capitalize_next = false;
599 } else {
600 result.push(c);
601 }
602 }
603
604 result
605}
606
607fn ensure_dir(dir: &Path) -> Result<()> {
608 fs::create_dir_all(dir)
609 .with_context(|| format!("Failed to create output directory {}", dir.display()))
610}
611
612fn rewrite_named_ref_to_namespace(value: &mut Value, ns: &str, name: &str) {
613 let direct = format!("#/definitions/{name}");
614 let prefixed = format!("{direct}/");
615 let replacement = format!("#/definitions/{ns}/{name}");
616 let replacement_prefixed = format!("{replacement}/");
617 match value {
618 Value::Object(obj) => {
619 if let Some(Value::String(reference)) = obj.get_mut("$ref") {
620 if reference == &direct {
621 *reference = replacement;
622 } else if let Some(rest) = reference.strip_prefix(&prefixed) {
623 *reference = format!("{replacement_prefixed}{rest}");
624 }
625 }
626 for child in obj.values_mut() {
627 rewrite_named_ref_to_namespace(child, ns, name);
628 }
629 }
630 Value::Array(items) => {
631 for child in items {
632 rewrite_named_ref_to_namespace(child, ns, name);
633 }
634 }
635 _ => {}
636 }
637}
638
639fn prepend_header_if_missing(path: &Path) -> Result<()> {
640 let mut content = String::new();
641 {
642 let mut f = fs::File::open(path)
643 .with_context(|| format!("Failed to open {} for reading", path.display()))?;
644 f.read_to_string(&mut content)
645 .with_context(|| format!("Failed to read {}", path.display()))?;
646 }
647
648 if content.starts_with(HEADER) {
649 return Ok(());
650 }
651
652 let mut f = fs::File::create(path)
653 .with_context(|| format!("Failed to open {} for writing", path.display()))?;
654 f.write_all(HEADER.as_bytes())
655 .with_context(|| format!("Failed to write header to {}", path.display()))?;
656 f.write_all(content.as_bytes())
657 .with_context(|| format!("Failed to write content to {}", path.display()))?;
658 Ok(())
659}
660
661fn ts_files_in(dir: &Path) -> Result<Vec<PathBuf>> {
662 let mut files = Vec::new();
663 for entry in
664 fs::read_dir(dir).with_context(|| format!("Failed to read dir {}", dir.display()))?
665 {
666 let entry = entry?;
667 let path = entry.path();
668 if path.is_file() && path.extension() == Some(OsStr::new("ts")) {
669 files.push(path);
670 }
671 }
672 files.sort();
673 Ok(files)
674}
675
676fn ts_files_in_recursive(dir: &Path) -> Result<Vec<PathBuf>> {
677 let mut files = Vec::new();
678 let mut stack = vec![dir.to_path_buf()];
679 while let Some(d) = stack.pop() {
680 for entry in
681 fs::read_dir(&d).with_context(|| format!("Failed to read dir {}", d.display()))?
682 {
683 let entry = entry?;
684 let path = entry.path();
685 if path.is_dir() {
686 stack.push(path);
687 } else if path.is_file() && path.extension() == Some(OsStr::new("ts")) {
688 files.push(path);
689 }
690 }
691 }
692 files.sort();
693 Ok(files)
694}
695
696fn generate_index_ts(out_dir: &Path) -> Result<PathBuf> {
699 let mut entries: Vec<String> = Vec::new();
700 let mut stems: Vec<String> = ts_files_in(out_dir)?
701 .into_iter()
702 .filter_map(|p| {
703 let stem = p.file_stem()?.to_string_lossy().into_owned();
704 if stem == "index" { None } else { Some(stem) }
705 })
706 .collect();
707 stems.sort();
708 stems.dedup();
709
710 for name in stems {
711 entries.push(format!("export type {{ {name} }} from \"./{name}\";\n"));
712 }
713
714 let v2_dir = out_dir.join("v2");
717 let has_v2_ts = ts_files_in(&v2_dir).map(|v| !v.is_empty()).unwrap_or(false);
718 if has_v2_ts {
719 entries.push("export * as v2 from \"./v2\";\n".to_string());
720 }
721
722 let mut content =
723 String::with_capacity(HEADER.len() + entries.iter().map(String::len).sum::<usize>());
724 content.push_str(HEADER);
725 for line in &entries {
726 content.push_str(line);
727 }
728
729 let index_path = out_dir.join("index.ts");
730 let mut f = fs::File::create(&index_path)
731 .with_context(|| format!("Failed to create {}", index_path.display()))?;
732 f.write_all(content.as_bytes())
733 .with_context(|| format!("Failed to write {}", index_path.display()))?;
734 Ok(index_path)
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740 use anyhow::Result;
741 use std::collections::BTreeSet;
742 use std::fs;
743 use std::path::PathBuf;
744 use uuid::Uuid;
745
746 #[test]
747 fn generated_ts_has_no_optional_nullable_fields() -> Result<()> {
748 let output_dir = std::env::temp_dir().join(format!("codex_ts_types_{}", Uuid::now_v7()));
750 fs::create_dir(&output_dir)?;
751
752 struct TempDirGuard(PathBuf);
753
754 impl Drop for TempDirGuard {
755 fn drop(&mut self) {
756 let _ = fs::remove_dir_all(&self.0);
757 }
758 }
759
760 let _guard = TempDirGuard(output_dir.clone());
761
762 let options = GenerateTsOptions {
764 generate_indices: false,
765 ensure_headers: false,
766 run_prettier: false,
767 };
768 generate_ts_with_options(&output_dir, None, options)?;
769
770 let mut undefined_offenders = Vec::new();
771 let mut optional_nullable_offenders = BTreeSet::new();
772 let mut stack = vec![output_dir];
773 while let Some(dir) = stack.pop() {
774 for entry in fs::read_dir(&dir)? {
775 let entry = entry?;
776 let path = entry.path();
777 if path.is_dir() {
778 stack.push(path);
779 continue;
780 }
781
782 if matches!(path.extension().and_then(|ext| ext.to_str()), Some("ts")) {
783 let contents = fs::read_to_string(&path)?;
784 if contents.contains("| undefined") {
785 undefined_offenders.push(path.clone());
786 }
787
788 const SKIP_PREFIXES: &[&str] = &[
789 "const ",
790 "let ",
791 "var ",
792 "export const ",
793 "export let ",
794 "export var ",
795 ];
796
797 let mut search_start = 0;
798 while let Some(idx) = contents[search_start..].find("| null") {
799 let abs_idx = search_start + idx;
800 let line_start_idx =
806 contents[..abs_idx].rfind('\n').map(|i| i + 1).unwrap_or(0);
807
808 let mut segment_start_idx = line_start_idx;
809 if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind(',') {
810 segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1);
811 }
812 if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind('{') {
813 segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1);
814 }
815 if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind('}') {
816 segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1);
817 }
818
819 let mut level_brace = 0_i32;
821 let mut level_brack = 0_i32;
822 let mut level_paren = 0_i32;
823 let mut in_single = false;
824 let mut in_double = false;
825 let mut escape = false;
826 let mut prop_colon_idx = None;
827 for (i, ch) in contents[segment_start_idx..abs_idx].char_indices() {
828 let idx_abs = segment_start_idx + i;
829 if escape {
830 escape = false;
831 continue;
832 }
833 match ch {
834 '\\' => {
835 if in_single || in_double {
837 escape = true;
838 }
839 }
840 '\'' => {
841 if !in_double {
842 in_single = !in_single;
843 }
844 }
845 '"' => {
846 if !in_single {
847 in_double = !in_double;
848 }
849 }
850 '{' if !in_single && !in_double => level_brace += 1,
851 '}' if !in_single && !in_double => level_brace -= 1,
852 '[' if !in_single && !in_double => level_brack += 1,
853 ']' if !in_single && !in_double => level_brack -= 1,
854 '(' if !in_single && !in_double => level_paren += 1,
855 ')' if !in_single && !in_double => level_paren -= 1,
856 ':' if !in_single
857 && !in_double
858 && level_brace == 0
859 && level_brack == 0
860 && level_paren == 0 =>
861 {
862 prop_colon_idx = Some(idx_abs);
863 break;
864 }
865 _ => {}
866 }
867 }
868
869 let Some(colon_idx) = prop_colon_idx else {
870 search_start = abs_idx + 5;
871 continue;
872 };
873
874 let mut field_prefix = contents[segment_start_idx..colon_idx].trim();
875 if field_prefix.is_empty() {
876 search_start = abs_idx + 5;
877 continue;
878 }
879
880 if let Some(comment_idx) = field_prefix.rfind("*/") {
881 field_prefix = field_prefix[comment_idx + 2..].trim_start();
882 }
883
884 if field_prefix.is_empty() {
885 search_start = abs_idx + 5;
886 continue;
887 }
888
889 if SKIP_PREFIXES
890 .iter()
891 .any(|prefix| field_prefix.starts_with(prefix))
892 {
893 search_start = abs_idx + 5;
894 continue;
895 }
896
897 if field_prefix.contains('(') {
898 search_start = abs_idx + 5;
899 continue;
900 }
901
902 if field_prefix.chars().rev().find(|c| !c.is_whitespace()) == Some('?') {
906 let line_number =
907 contents[..abs_idx].chars().filter(|c| *c == '\n').count() + 1;
908 let offending_line_end = contents[line_start_idx..]
909 .find('\n')
910 .map(|i| line_start_idx + i)
911 .unwrap_or(contents.len());
912 let offending_snippet =
913 contents[line_start_idx..offending_line_end].trim();
914
915 optional_nullable_offenders.insert(format!(
916 "{}:{}: {offending_snippet}",
917 path.display(),
918 line_number
919 ));
920 }
921
922 search_start = abs_idx + 5;
923 }
924 }
925 }
926 }
927
928 assert!(
929 undefined_offenders.is_empty(),
930 "Generated TypeScript still includes unions with `undefined` in {undefined_offenders:?}"
931 );
932
933 assert!(
937 optional_nullable_offenders.is_empty(),
938 "Generated TypeScript has optional fields with nullable types (disallowed '?: T | null'), add #[ts(optional)] to fix:\n{optional_nullable_offenders:?}"
939 );
940
941 Ok(())
942 }
943}