Skip to main content

codex_app_server_protocol/
export.rs

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    // Ensure our header is present on all TS files (root + subdirs like v2/).
108    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    // Optionally run Prettier on all generated TS files.
122    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    // If the name looks like a namespaced path (e.g., "v2::Type"), mirror
296    // the TypeScript layout and write to out_dir/v2/Type.json. Otherwise
297    // write alongside the legacy files.
298    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
335/// Split a fully-qualified type name like "v2::Type" into its namespace and logical name.
336fn split_namespace(name: &str) -> (Option<&str>, &str) {
337    name.split_once("::")
338        .map_or((None, name), |(ns, rest)| (Some(ns), rest))
339}
340
341/// Recursively rewrite $ref values that point at "#/definitions/..." so that
342/// they point to a namespaced location under the bundle.
343fn 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
696/// Generate an index.ts file that re-exports all generated types.
697/// This allows consumers to import all types from a single file.
698fn 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    // If this is the root out_dir and a ./v2 folder exists with TS files,
715    // expose it as a namespace to avoid symbol collisions at the root.
716    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        // Assert that there are no types of the form "?: T | null" in the generated TS files.
749        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        // Avoid doing more work than necessary to keep the test from timing out.
763        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                        // Find the property-colon for this field by scanning forward
801                        // from the start of the segment and ignoring nested braces,
802                        // brackets, and parens. This avoids colons inside nested
803                        // type literals like `{ [k in string]?: string }`.
804
805                        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                        // Scan forward for the colon that separates the field name from its type.
820                        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                                    // Only treat as escape when inside a string.
836                                    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 the last non-whitespace before ':' is '?', then this is an
903                        // optional field with a nullable type (i.e., "?: T | null"),
904                        // which we explicitly disallow.
905                        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        // If this assertion fails, it means a field was generated as
934        // "?: T | null" — i.e., both optional (undefined) and nullable (null).
935        // We only want either "?: T" or ": T | null".
936        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}