Skip to main content

mcp_execution_codegen/common/
typescript.rs

1//! TypeScript code generation utilities.
2//!
3//! Provides functions to convert JSON Schema to TypeScript types
4//! and generate type-safe TypeScript code.
5//!
6//! # Examples
7//!
8//! ```
9//! use mcp_execution_codegen::common::typescript;
10//! use serde_json::json;
11//!
12//! let schema = json!({
13//!     "type": "object",
14//!     "properties": {
15//!         "name": {"type": "string"},
16//!         "age": {"type": "number"}
17//!     }
18//! });
19//!
20//! let ts_type = typescript::json_schema_to_typescript(&schema);
21//! ```
22
23use serde_json::Value;
24use std::collections::HashSet;
25
26/// Maximum nesting depth [`json_schema_to_typescript`] will descend into before treating the
27/// remainder of a branch as opaque, rather than recursing further.
28///
29/// The `JSDoc` description sanitizer in `progressive::generator` uses the same cap. Both
30/// functions recurse into every nested `object`/`array` schema with no depth limit of
31/// their own, and `mcp_execution_introspector::MAX_SCHEMA_SIZE_BYTES` bounds a schema's
32/// *serialized byte size*, not its nesting depth — so nothing in this crate stops a caller from
33/// handing either function an arbitrarily deep, directly-constructed `serde_json::Value` (e.g.
34/// built by hand via `Value::Object`/`Map`, as in this module's own tests). That is the actual
35/// threat this constant defends against: **not** a schema arriving over the wire from a target
36/// MCP server. `input_schema` there is deserialized by `serde_json` from a `tools/list`
37/// response, and this workspace never raises or disables that deserializer's own default
38/// recursion limit (128 — no `disable_recursion_limit`/`unbounded_depth` anywhere in the
39/// dependency tree). Measured through a real `tools/list` envelope, that limit caps *reachable*
40/// nesting at 122 levels for an array-shaped schema and ~61 levels for `properties`-nested
41/// objects (each object level costs two parser recursion levels: the wrapper and its
42/// `properties` map) — a server that sends anything deeper gets its response line dropped as a
43/// parse error before `mcp-execution-introspector` ever builds a `ToolInfo` from it, let alone
44/// hands it to this crate. So on the wire path, this cap can never fire, let alone protect
45/// against a stack overflow.
46///
47/// This cap is therefore defense-in-depth for direct callers of these two functions — bounding
48/// a `Value` handed straight to `json_schema_to_typescript` or the `JSDoc` sanitizer, regardless
49/// of provenance — not a fix for a reachable wire-path denial of service, and **not** a
50/// guarantee that covers the rest of the `ProgressiveGenerator::generate`/
51/// `generate_with_categories` pipeline built on top of them. That pipeline has other
52/// unconditionally-recursive touches on the same schema this cap does not reach: e.g.
53/// `progressive::generator`'s `create_tool_context` clones `tool.input_schema` (an
54/// unguarded, recursive `Value::Clone`) before ever calling the capped sanitizer, and the
55/// schema is later re-serialized (`serde_json::to_vec`) and rendered through Handlebars, and
56/// eventually dropped (`Value`'s `Drop` impl is itself recursive) — none of which this constant
57/// bounds. 128 is set at the wire path's own ceiling (comfortably above the 122 levels a real
58/// `tools/list` response can ever produce, so no schema that actually arrives via introspection
59/// is ever clipped) while still bounding the unconditionally-recursive descent for a `Value`
60/// passed directly to either of these two functions.
61///
62/// That ceiling is coupled to `serde_json`'s own default recursion limit staying at 128: this
63/// value isn't an arbitrary round number, it's chosen to sit at the wire path's current ceiling
64/// specifically because that upstream default is 128. If a future `serde_json` release changes
65/// its own default, or this workspace ever configures a different limit, this constant's "never
66/// clips a real wire schema" property needs re-verifying against the new ceiling.
67///
68/// # Examples
69///
70/// ```
71/// use mcp_execution_codegen::common::typescript::MAX_SCHEMA_RECURSION_DEPTH;
72///
73/// assert!(MAX_SCHEMA_RECURSION_DEPTH > 0);
74/// ```
75pub const MAX_SCHEMA_RECURSION_DEPTH: usize = 128;
76
77/// Converts a `snake_case` name to camelCase for TypeScript.
78///
79/// # Examples
80///
81/// ```
82/// use mcp_execution_codegen::common::typescript::to_camel_case;
83///
84/// assert_eq!(to_camel_case("send_message"), "sendMessage");
85/// assert_eq!(to_camel_case("get_user_data"), "getUserData");
86/// assert_eq!(to_camel_case("hello"), "hello");
87/// ```
88#[must_use]
89pub fn to_camel_case(snake_case: &str) -> String {
90    let mut result = String::new();
91    let mut capitalize_next = false;
92
93    for ch in snake_case.chars() {
94        if ch == '_' {
95            capitalize_next = true;
96        } else if capitalize_next {
97            result.push(ch.to_ascii_uppercase());
98            capitalize_next = false;
99        } else {
100            result.push(ch);
101        }
102    }
103
104    result
105}
106
107/// Converts a `snake_case` name to `PascalCase` for TypeScript types.
108///
109/// # Examples
110///
111/// ```
112/// use mcp_execution_codegen::common::typescript::to_pascal_case;
113///
114/// assert_eq!(to_pascal_case("send_message"), "SendMessage");
115/// assert_eq!(to_pascal_case("get_user_data"), "GetUserData");
116/// assert_eq!(to_pascal_case("hello"), "Hello");
117/// ```
118#[must_use]
119pub fn to_pascal_case(snake_case: &str) -> String {
120    let camel = to_camel_case(snake_case);
121    let mut chars = camel.chars();
122    chars.next().map_or_else(String::new, |first| {
123        first.to_uppercase().collect::<String>() + chars.as_str()
124    })
125}
126
127/// Sanitizes a string for safe use as a TypeScript identifier (e.g. a function, export,
128/// or object property name).
129///
130/// Characters in `[A-Za-z0-9$]` pass through unchanged; every other character — including a
131/// literal `_` already in the input — is treated as a separator and replaces its whole
132/// consecutive run with a single `_`, and the result is prefixed with `_` if it would
133/// otherwise start with a digit or be empty. This prevents identifier-position injection of
134/// arbitrary TypeScript syntax from untrusted schema data (property keys, tool names, etc.
135/// sourced from an MCP server are not guaranteed to be valid identifiers).
136///
137/// Collapsing an entire separator run — rather than emitting one `_` per invalid character —
138/// means e.g. a multi-byte non-ASCII run doesn't balloon into a wall of underscores that
139/// discards more information than a single separator already would. It also means a literal
140/// run like `"a__b"` collapses to `"a_b"`: once a separator has been emitted, further
141/// separator-producing characters are redundant regardless of why each one would have
142/// produced a `_`.
143///
144/// # Examples
145///
146/// ```
147/// use mcp_execution_codegen::common::typescript::sanitize_ts_identifier;
148///
149/// assert_eq!(sanitize_ts_identifier("valid_name"), "valid_name");
150/// assert_eq!(sanitize_ts_identifier("123abc"), "_123abc");
151/// assert_eq!(sanitize_ts_identifier("a-b c"), "a_b_c");
152/// assert_eq!(sanitize_ts_identifier("café_menu_日本語"), "caf_menu_");
153/// ```
154#[must_use]
155pub fn sanitize_ts_identifier(s: &str) -> String {
156    let mut result = String::new();
157    let mut prev_was_underscore = false;
158    for c in s.chars() {
159        if c.is_ascii_alphanumeric() || c == '$' {
160            result.push(c);
161            prev_was_underscore = false;
162        } else if !prev_was_underscore {
163            result.push('_');
164            prev_was_underscore = true;
165        }
166    }
167
168    if result.is_empty() || result.starts_with(|c: char| c.is_ascii_digit()) {
169        result.insert(0, '_');
170    }
171
172    result
173}
174
175/// Disambiguates `base` against the `used` set by appending a numeric suffix
176/// (`_2`, `_3`, ...) until the candidate is not already present, then reserves the
177/// winning candidate by inserting it into `used`.
178///
179/// Shared by every call site that maps untrusted, sanitized names into a namespace where
180/// distinct source names can collide after sanitization (e.g. sibling schema keys or tool
181/// names) — collisions must be disambiguated deterministically rather than silently
182/// overwriting one another.
183pub(crate) fn disambiguate_identifier(base: &str, used: &mut HashSet<String>) -> String {
184    let mut candidate = base.to_string();
185    let mut suffix = 2;
186    while !used.insert(candidate.clone()) {
187        candidate = format!("{base}_{suffix}");
188        suffix += 1;
189    }
190    candidate
191}
192
193/// Converts JSON Schema type to TypeScript type.
194///
195/// Maps JSON Schema primitive types to their TypeScript equivalents.
196///
197/// # Examples
198///
199/// ```
200/// use mcp_execution_codegen::common::typescript::json_type_to_typescript;
201///
202/// assert_eq!(json_type_to_typescript("string"), "string");
203/// assert_eq!(json_type_to_typescript("number"), "number");
204/// assert_eq!(json_type_to_typescript("integer"), "number");
205/// assert_eq!(json_type_to_typescript("boolean"), "boolean");
206/// assert_eq!(json_type_to_typescript("unknown_type"), "unknown");
207/// ```
208#[must_use]
209pub fn json_type_to_typescript(json_type: &str) -> &'static str {
210    match json_type {
211        "string" => "string",
212        "number" | "integer" => "number",
213        "boolean" => "boolean",
214        "array" => "unknown[]",
215        "object" => "Record<string, unknown>",
216        "null" => "null",
217        _ => "unknown",
218    }
219}
220
221/// Converts a JSON Schema to TypeScript type definition.
222///
223/// Handles complex schemas including objects, arrays, and nested types.
224///
225/// # Examples
226///
227/// ```
228/// use mcp_execution_codegen::common::typescript::json_schema_to_typescript;
229/// use serde_json::json;
230///
231/// let schema = json!({
232///     "type": "object",
233///     "properties": {
234///         "name": {"type": "string"},
235///         "age": {"type": "number"}
236///     },
237///     "required": ["name"]
238/// });
239///
240/// let ts = json_schema_to_typescript(&schema);
241/// assert!(ts.contains("name: string"));
242/// ```
243#[must_use]
244pub fn json_schema_to_typescript(schema: &Value) -> String {
245    let mut cap_hit = false;
246    let ts_type = json_schema_to_typescript_at_depth(schema, 0, &mut cap_hit);
247    if cap_hit {
248        // Known limitation: `extract_properties` calls this function once per top-level
249        // property, so a schema whose properties each nest deeply logs once per affected
250        // property rather than once per tool, and this warning carries no tool/server
251        // identifier to correlate it back to the originating `generate_with_categories` call.
252        tracing::warn!(
253            max_depth = MAX_SCHEMA_RECURSION_DEPTH,
254            "schema nesting exceeded MAX_SCHEMA_RECURSION_DEPTH; branches beyond that depth were \
255             rendered as an opaque `unknown` type"
256        );
257    }
258    ts_type
259}
260
261/// Depth-tracked implementation backing [`json_schema_to_typescript`].
262///
263/// Once `depth` reaches [`MAX_SCHEMA_RECURSION_DEPTH`], the current branch is treated as
264/// opaque (`unknown`/`unknown[]`) instead of recursing further — see that constant's docs for
265/// what this cap actually defends against. `cap_hit` is set (never cleared) the first time any
266/// branch trips the cap, so the public wrapper can log once per call rather than once per
267/// clipped branch.
268fn json_schema_to_typescript_at_depth(schema: &Value, depth: usize, cap_hit: &mut bool) -> String {
269    if depth >= MAX_SCHEMA_RECURSION_DEPTH {
270        *cap_hit = true;
271        return "unknown".to_string();
272    }
273
274    match schema {
275        Value::Object(obj) => {
276            // Get type field
277            let schema_type = obj
278                .get("type")
279                .and_then(|v| v.as_str())
280                .unwrap_or("unknown");
281
282            match schema_type {
283                "object" => {
284                    // Extract properties
285                    let properties = obj.get("properties").and_then(|v| v.as_object());
286                    let required = obj
287                        .get("required")
288                        .and_then(|v| v.as_array())
289                        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
290                        .unwrap_or_default();
291
292                    properties.map_or_else(
293                        || "Record<string, unknown>".to_string(),
294                        |props| {
295                            let mut fields = Vec::new();
296                            let mut used_keys = HashSet::new();
297                            for (key, value) in props {
298                                let is_required = required.contains(&key.as_str());
299                                let optional_marker = if is_required { "" } else { "?" };
300                                let ts_type =
301                                    json_schema_to_typescript_at_depth(value, depth + 1, cap_hit);
302                                let base_key = sanitize_ts_identifier(key);
303                                let safe_key = disambiguate_identifier(&base_key, &mut used_keys);
304                                fields.push(format!("  {safe_key}{optional_marker}: {ts_type};"));
305                            }
306
307                            if fields.is_empty() {
308                                "Record<string, unknown>".to_string()
309                            } else {
310                                format!("{{\n{}\n}}", fields.join("\n"))
311                            }
312                        },
313                    )
314                }
315                "array" => obj.get("items").map_or_else(
316                    || "unknown[]".to_string(),
317                    |item_schema| {
318                        format!(
319                            "{}[]",
320                            json_schema_to_typescript_at_depth(item_schema, depth + 1, cap_hit)
321                        )
322                    },
323                ),
324                other => json_type_to_typescript(other).to_string(),
325            }
326        }
327        Value::String(s) => json_type_to_typescript(s).to_string(),
328        _ => "unknown".to_string(),
329    }
330}
331
332/// Extracts property definitions from JSON Schema for template rendering.
333///
334/// Returns a vector of property information suitable for Handlebars templates.
335///
336/// Calls [`json_schema_to_typescript`] once per top-level property, starting each call fresh
337/// at depth 0 — the `schema`'s own `type: "object"` wrapper and `properties` map are peeled off
338/// by this function rather than counted by [`MAX_SCHEMA_RECURSION_DEPTH`]. So the real
339/// per-tool call path (`ProgressiveGenerator::extract_property_infos` /
340/// `extract_property_data`, both built on this function) tolerates one level more of true JSON
341/// nesting in a tool's raw `input_schema` than the constant's value alone would suggest: a
342/// property value schema can nest `MAX_SCHEMA_RECURSION_DEPTH` levels *below* the property
343/// itself before clipping, for `MAX_SCHEMA_RECURSION_DEPTH + 1` raw levels overall.
344///
345/// # Examples
346///
347/// ```
348/// use mcp_execution_codegen::common::typescript::extract_properties;
349/// use serde_json::json;
350///
351/// let schema = json!({
352///     "type": "object",
353///     "properties": {
354///         "name": {"type": "string"},
355///         "age": {"type": "number"}
356///     },
357///     "required": ["name"]
358/// });
359///
360/// let props = extract_properties(&schema);
361/// assert_eq!(props.len(), 2);
362/// ```
363#[must_use]
364pub fn extract_properties(schema: &Value) -> Vec<serde_json::Value> {
365    let mut properties = Vec::new();
366
367    if let Some(obj) = schema.as_object()
368        && let Some(props) = obj.get("properties").and_then(|v| v.as_object())
369    {
370        let required = obj
371            .get("required")
372            .and_then(|v| v.as_array())
373            .map(|arr| {
374                arr.iter()
375                    .filter_map(|v| v.as_str())
376                    .map(String::from)
377                    .collect::<Vec<_>>()
378            })
379            .unwrap_or_default();
380
381        for (name, prop_schema) in props {
382            let ts_type = json_schema_to_typescript(prop_schema);
383            let is_required = required.contains(name);
384
385            properties.push(serde_json::json!({
386                "name": name,
387                "type": ts_type,
388                "required": is_required,
389            }));
390        }
391    }
392
393    properties
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use serde_json::json;
400
401    #[test]
402    fn test_to_camel_case() {
403        assert_eq!(to_camel_case("send_message"), "sendMessage");
404        assert_eq!(to_camel_case("get_user_data"), "getUserData");
405        assert_eq!(to_camel_case("hello"), "hello");
406        assert_eq!(to_camel_case("a_b_c"), "aBC");
407    }
408
409    #[test]
410    fn test_to_pascal_case() {
411        assert_eq!(to_pascal_case("send_message"), "SendMessage");
412        assert_eq!(to_pascal_case("get_user_data"), "GetUserData");
413        assert_eq!(to_pascal_case("hello"), "Hello");
414    }
415
416    #[test]
417    fn test_json_type_to_typescript() {
418        assert_eq!(json_type_to_typescript("string"), "string");
419        assert_eq!(json_type_to_typescript("number"), "number");
420        assert_eq!(json_type_to_typescript("integer"), "number");
421        assert_eq!(json_type_to_typescript("boolean"), "boolean");
422        assert_eq!(json_type_to_typescript("array"), "unknown[]");
423        assert_eq!(json_type_to_typescript("object"), "Record<string, unknown>");
424        assert_eq!(json_type_to_typescript("null"), "null");
425        assert_eq!(json_type_to_typescript("unknown_type"), "unknown");
426    }
427
428    #[test]
429    fn test_json_schema_to_typescript_primitive() {
430        assert_eq!(
431            json_schema_to_typescript(&json!({"type": "string"})),
432            "string"
433        );
434        assert_eq!(
435            json_schema_to_typescript(&json!({"type": "number"})),
436            "number"
437        );
438    }
439
440    #[test]
441    fn test_json_schema_to_typescript_object() {
442        let schema = json!({
443            "type": "object",
444            "properties": {
445                "name": {"type": "string"},
446                "age": {"type": "number"}
447            },
448            "required": ["name"]
449        });
450
451        let result = json_schema_to_typescript(&schema);
452        assert!(result.contains("name: string"));
453        assert!(result.contains("age?: number"));
454    }
455
456    #[test]
457    fn test_json_schema_to_typescript_object_sanitizes_nested_keys() {
458        let malicious_key = "x }; export const pwned = evil(); interface J {";
459        let schema = json!({
460            "type": "object",
461            "properties": {
462                malicious_key: {"type": "string"}
463            },
464            "required": []
465        });
466
467        let result = json_schema_to_typescript(&schema);
468        assert!(!result.contains("export const pwned"));
469        assert!(!result.contains(malicious_key));
470        assert!(result.contains(&sanitize_ts_identifier(malicious_key)));
471    }
472
473    #[test]
474    fn test_json_schema_to_typescript_dedups_colliding_sibling_keys() {
475        // "a-b" and "a.b" both sanitize to "a_b"; the second must be disambiguated
476        // rather than producing a duplicate field name in the generated interface.
477        let schema = json!({
478            "type": "object",
479            "properties": {
480                "a-b": {"type": "string"},
481                "a.b": {"type": "number"}
482            },
483            "required": []
484        });
485
486        let result = json_schema_to_typescript(&schema);
487        // Exact field-line matches: a substring count on "a_b" would also match "a_b_2",
488        // so assert on the full `key?: type` field lines instead.
489        assert_eq!(result.matches("a_b?: string").count(), 1, "{result}");
490        assert_eq!(result.matches("a_b_2?: number").count(), 1, "{result}");
491    }
492
493    #[test]
494    fn test_json_schema_to_typescript_dedups_three_way_colliding_sibling_keys() {
495        let schema = json!({
496            "type": "object",
497            "properties": {
498                "a-b": {"type": "string"},
499                "a.b": {"type": "number"},
500                "a b": {"type": "boolean"}
501            },
502            "required": []
503        });
504
505        let result = json_schema_to_typescript(&schema);
506        // The `preserve_order` feature is enabled transitively (via schemars/rmcp), so
507        // `serde_json::Map` iterates in insertion order: "a-b" claims the base "a_b" first.
508        assert_eq!(result.matches("a_b?: string").count(), 1, "{result}");
509        assert_eq!(result.matches("a_b_2?: number").count(), 1, "{result}");
510        assert_eq!(result.matches("a_b_3?: boolean").count(), 1, "{result}");
511    }
512
513    #[test]
514    fn test_json_schema_to_typescript_dedups_colliding_keys_in_nested_object() {
515        let schema = json!({
516            "type": "object",
517            "properties": {
518                "outer": {
519                    "type": "object",
520                    "properties": {
521                        "a-b": {"type": "string"},
522                        "a.b": {"type": "number"}
523                    },
524                    "required": []
525                }
526            },
527            "required": []
528        });
529
530        let result = json_schema_to_typescript(&schema);
531        assert!(result.contains("a_b?: string"), "{result}");
532        assert!(result.contains("a_b_2?: number"), "{result}");
533    }
534
535    #[test]
536    fn test_disambiguate_identifier_reuses_base_across_independent_scopes() {
537        // Disambiguation state must not leak between unrelated objects: two sibling nested
538        // objects each independently reusing "a_b" as a field name is not a collision.
539        let schema = json!({
540            "type": "object",
541            "properties": {
542                "first": {
543                    "type": "object",
544                    "properties": {"a_b": {"type": "string"}},
545                    "required": []
546                },
547                "second": {
548                    "type": "object",
549                    "properties": {"a_b": {"type": "number"}},
550                    "required": []
551                }
552            },
553            "required": []
554        });
555
556        let result = json_schema_to_typescript(&schema);
557        assert!(!result.contains("a_b_2"), "{result}");
558    }
559
560    #[test]
561    fn test_sanitize_ts_identifier_replaces_invalid_characters() {
562        assert_eq!(sanitize_ts_identifier("a-b c"), "a_b_c");
563    }
564
565    #[test]
566    fn test_sanitize_ts_identifier_prefixes_leading_digit() {
567        assert_eq!(sanitize_ts_identifier("123name"), "_123name");
568    }
569
570    #[test]
571    fn test_sanitize_ts_identifier_prefixes_empty_string() {
572        assert_eq!(sanitize_ts_identifier(""), "_");
573    }
574
575    #[test]
576    fn test_sanitize_ts_identifier_collapses_consecutive_non_ascii_run() {
577        // Issue #192: each invalid char used to become its own `_`, so one accented
578        // letter plus a run of CJK characters produced four separate underscores. The
579        // literal `_`s between words must also collapse into the run rather than
580        // surviving as a second, adjacent separator (otherwise `sanitize_ts_identifier`
581        // alone — as used for property names, without `to_camel_case` first — still
582        // produces `caf__menu__`, the exact artifact the issue was filed about).
583        assert_eq!(sanitize_ts_identifier("café_menu_日本語"), "caf_menu_");
584    }
585
586    #[test]
587    fn test_sanitize_ts_identifier_collapses_mixed_invalid_run() {
588        assert_eq!(sanitize_ts_identifier("a---b"), "a_b");
589        assert_eq!(sanitize_ts_identifier("a- .b"), "a_b");
590    }
591
592    #[test]
593    fn test_sanitize_ts_identifier_collapses_literal_underscore_runs() {
594        // A run of literal `_`s in the input collapses too: once a separator has been
595        // emitted, further separator-producing characters (whether literal `_` or a
596        // substituted invalid character) are redundant regardless of which kind they are.
597        assert_eq!(sanitize_ts_identifier("a__b"), "a_b");
598        assert_eq!(sanitize_ts_identifier("日_本"), "_");
599        assert_eq!(sanitize_ts_identifier("_日_"), "_");
600    }
601
602    #[test]
603    fn test_sanitize_ts_identifier_preserves_isolated_invalid_chars() {
604        // A single invalid character separated from the next by a valid character must still
605        // become its own `_` — only *consecutive* separator-producing runs collapse.
606        assert_eq!(sanitize_ts_identifier("a-b-c"), "a_b_c");
607    }
608
609    #[test]
610    fn test_sanitize_ts_identifier_all_invalid_chars_collapses_to_bare_underscore() {
611        // The most extreme case of issue #192: an identifier made entirely of invalid
612        // characters must collapse to a single `_`, not one `_` per character. Distinct
613        // from the empty-string case (`""` never enters the loop at all).
614        assert_eq!(sanitize_ts_identifier("日本語"), "_");
615        assert_eq!(sanitize_ts_identifier("---"), "_");
616    }
617
618    #[test]
619    fn test_json_schema_to_typescript_array() {
620        let schema = json!({
621            "type": "array",
622            "items": {"type": "string"}
623        });
624
625        assert_eq!(json_schema_to_typescript(&schema), "string[]");
626    }
627
628    /// Builds a schema with `depth` nested `type: "array"` levels wrapping a `string` leaf.
629    ///
630    /// Assembles each level directly via `serde_json::Map`/`Value` (rather than the `json!`
631    /// macro, which round-trips embedded values through `to_value`'s own recursive
632    /// serializer) so building this pathological fixture can't itself overflow the stack.
633    fn nested_array_schema(depth: usize) -> Value {
634        let mut schema = json!({"type": "string"});
635        for _ in 0..depth {
636            let mut map = serde_json::Map::new();
637            map.insert("type".to_string(), Value::String("array".to_string()));
638            map.insert("items".to_string(), schema);
639            schema = Value::Object(map);
640        }
641        schema
642    }
643
644    /// Builds a schema with `depth` nested `type: "object"` levels (each with a single
645    /// property `"a"`) wrapping a `string` leaf, for the same construction reason as
646    /// [`nested_array_schema`].
647    fn nested_object_schema(depth: usize) -> Value {
648        let mut schema = json!({"type": "string"});
649        for _ in 0..depth {
650            let mut properties = serde_json::Map::new();
651            properties.insert("a".to_string(), schema);
652            let mut map = serde_json::Map::new();
653            map.insert("type".to_string(), Value::String("object".to_string()));
654            map.insert("properties".to_string(), Value::Object(properties));
655            map.insert("required".to_string(), Value::Array(Vec::new()));
656            schema = Value::Object(map);
657        }
658        schema
659    }
660
661    /// Runs `f` on a dedicated thread with a generous stack, and returns its result.
662    ///
663    /// `serde_json::Value`'s own `Drop` impl is recursive and unrelated to this module's fix:
664    /// a `Value` nested 5,000+ levels deep overflows the *default* thread stack merely by
665    /// going out of scope, regardless of how it was traversed beforehand. That's not
666    /// reachable in production — `serde_json`'s deserializer already rejects JSON nested
667    /// beyond its own default recursion limit (well under 5,000) before a `Value` this deep
668    /// can ever be constructed from the wire — but a test fixture built directly via
669    /// `Value`/`Map` (bypassing deserialization, see [`nested_array_schema`]) still has to
670    /// survive being dropped at the end of the test. A larger stack lets construction, the
671    /// (now depth-capped) function under test, and teardown all complete without that
672    /// unrelated limitation masking a pass.
673    fn run_on_large_stack<F: FnOnce() + Send + 'static>(f: F) {
674        std::thread::Builder::new()
675            .stack_size(64 * 1024 * 1024)
676            .spawn(f)
677            .expect("spawn test thread")
678            .join()
679            .expect("test thread panicked");
680    }
681
682    #[test]
683    fn test_json_schema_to_typescript_bounds_deeply_nested_array() {
684        // Issue #303: verifies the recursion-depth cap actually bounds this function's
685        // descent for a `Value` nested far beyond MAX_SCHEMA_RECURSION_DEPTH (see that
686        // constant's docs — this is defense-in-depth for the `pub` API, not a reachable
687        // wire-path scenario). Past the cap, the type must degrade to a well-formed
688        // `unknown[]...[]` string rather than continuing to recurse.
689        run_on_large_stack(|| {
690            let schema = nested_array_schema(5_000);
691            let result = json_schema_to_typescript(&schema);
692            assert!(result.starts_with("unknown"), "{result}");
693            assert!(result.ends_with("[]"), "{result}");
694        });
695    }
696
697    #[test]
698    fn test_json_schema_to_typescript_bounds_deeply_nested_object() {
699        // Same cap-behavior check as the array case above, for object nesting.
700        run_on_large_stack(|| {
701            let schema = nested_object_schema(5_000);
702            let result = json_schema_to_typescript(&schema);
703            assert!(result.contains("unknown"), "{result}");
704        });
705    }
706
707    #[test]
708    fn test_extract_properties() {
709        let schema = json!({
710            "type": "object",
711            "properties": {
712                "name": {"type": "string"},
713                "age": {"type": "number"}
714            },
715            "required": ["name"]
716        });
717
718        let props = extract_properties(&schema);
719        assert_eq!(props.len(), 2);
720
721        // Find the "name" property (HashMap order is not guaranteed)
722        let name_prop = props
723            .iter()
724            .find(|p| p["name"] == "name")
725            .expect("name property not found");
726
727        assert_eq!(name_prop["type"], "string");
728        assert_eq!(name_prop["required"], true);
729
730        // Check age property
731        let age_prop = props
732            .iter()
733            .find(|p| p["name"] == "age")
734            .expect("age property not found");
735
736        assert_eq!(age_prop["type"], "number");
737        assert_eq!(age_prop["required"], false);
738    }
739
740    #[test]
741    fn test_extract_properties_empty() {
742        let schema = json!({"type": "string"});
743        let props = extract_properties(&schema);
744        assert_eq!(props.len(), 0);
745    }
746}