Skip to main content

normalize_openapi/
lib.rs

1//! OpenAPI client code generation.
2//!
3//! Trait-based design allows multiple implementations per language/framework.
4//!
5//! # Extensibility
6//!
7//! Users can register custom generators via [`register()`]:
8//!
9//! ```ignore
10//! use normalize_openapi::{OpenApiClientGenerator, register};
11//! use serde_json::Value;
12//!
13//! struct MyGenerator;
14//!
15//! impl OpenApiClientGenerator for MyGenerator {
16//!     fn language(&self) -> &'static str { "mylang" }
17//!     fn variant(&self) -> &'static str { "myvariant" }
18//!     fn generate(&self, spec: &Value) -> String { /* ... */ }
19//! }
20//!
21//! // Register before first use
22//! register(&MyGenerator);
23//! ```
24
25use serde_json::Value;
26use std::sync::{OnceLock, RwLock};
27
28/// A code generator for a specific language/framework.
29pub trait OpenApiClientGenerator: Send + Sync {
30    /// Language name (e.g., "typescript", "python")
31    fn language(&self) -> &'static str;
32
33    /// Framework/variant name (e.g., "fetch", "axios", "urllib")
34    fn variant(&self) -> &'static str;
35
36    /// Generate client code from OpenAPI JSON.
37    fn generate(&self, spec: &Value) -> String;
38}
39
40/// Global registry of generator plugins.
41static GENERATORS: RwLock<Vec<&'static dyn OpenApiClientGenerator>> = RwLock::new(Vec::new());
42static INITIALIZED: OnceLock<()> = OnceLock::new();
43
44/// Register a custom generator plugin.
45///
46/// Call this before any generation operations to add custom generators.
47/// Built-in generators are registered automatically on first use.
48pub fn register(generator: &'static dyn OpenApiClientGenerator) {
49    GENERATORS.write().unwrap().push(generator);
50}
51
52/// Initialize built-in generators (called automatically on first use).
53fn init_builtin() {
54    INITIALIZED.get_or_init(|| {
55        let mut generators = GENERATORS.write().unwrap();
56        static TS: TypeScriptFetch = TypeScriptFetch;
57        static PY: PythonUrllib = PythonUrllib;
58        static RS: RustUreq = RustUreq;
59        generators.push(&TS);
60        generators.push(&PY);
61        generators.push(&RS);
62    });
63}
64
65/// Get a generator by language from the global registry (returns first match).
66pub fn get_generator(lang: &str) -> Option<&'static dyn OpenApiClientGenerator> {
67    init_builtin();
68    let lang_lower = lang.to_lowercase();
69    GENERATORS
70        .read()
71        .unwrap()
72        .iter()
73        .find(|g| {
74            g.language() == lang_lower
75                || (lang_lower == "ts" && g.language() == "typescript")
76                || (lang_lower == "py" && g.language() == "python")
77                || (lang_lower == "rs" && g.language() == "rust")
78        })
79        .copied()
80}
81
82/// List all available generators (language, variant) from the global registry.
83pub fn list_generators() -> Vec<(&'static str, &'static str)> {
84    init_builtin();
85    GENERATORS
86        .read()
87        .unwrap()
88        .iter()
89        .map(|g| (g.language(), g.variant()))
90        .collect()
91}
92
93// Backwards-compatible aliases
94/// Find a generator by language (alias for get_generator, returns Box for compatibility).
95pub fn find_generator(lang: &str) -> Option<Box<dyn OpenApiClientGenerator>> {
96    get_generator(lang).map(|g| Box::new(GeneratorWrapper(g)) as Box<dyn OpenApiClientGenerator>)
97}
98
99struct GeneratorWrapper(&'static dyn OpenApiClientGenerator);
100
101impl OpenApiClientGenerator for GeneratorWrapper {
102    fn language(&self) -> &'static str {
103        self.0.language()
104    }
105
106    fn variant(&self) -> &'static str {
107        self.0.variant()
108    }
109
110    fn generate(&self, spec: &Value) -> String {
111        self.0.generate(spec)
112    }
113}
114
115/// Registry of available generators (returns boxed generators for compatibility).
116pub fn generators() -> Vec<Box<dyn OpenApiClientGenerator>> {
117    init_builtin();
118    GENERATORS
119        .read()
120        .unwrap()
121        .iter()
122        .map(|g| Box::new(GeneratorWrapper(*g)) as Box<dyn OpenApiClientGenerator>)
123        .collect()
124}
125
126// --- TypeScript (fetch) ---
127
128struct TypeScriptFetch;
129
130impl OpenApiClientGenerator for TypeScriptFetch {
131    fn language(&self) -> &'static str {
132        "typescript"
133    }
134    fn variant(&self) -> &'static str {
135        "fetch"
136    }
137
138    fn generate(&self, spec: &Value) -> String {
139        let mut out = String::new();
140        out.push_str("// Auto-generated from OpenAPI spec\n");
141        out.push_str("// Uses fetch (built-in)\n\n");
142
143        // Generate interfaces from schemas
144        if let Some(schemas) = spec
145            .pointer("/components/schemas")
146            .and_then(|s| s.as_object())
147        {
148            for (name, schema) in schemas {
149                out.push_str(&format!("export interface {} {{\n", name));
150                if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
151                    let required: Vec<&str> = schema
152                        .get("required")
153                        .and_then(|r| r.as_array())
154                        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
155                        .unwrap_or_default();
156                    for (prop_name, prop) in props {
157                        let ts_type = json_schema_to_ts(prop);
158                        let opt = if required.contains(&prop_name.as_str()) {
159                            ""
160                        } else {
161                            "?"
162                        };
163                        out.push_str(&format!("  {}{}: {};\n", prop_name, opt, ts_type));
164                    }
165                }
166                out.push_str("}\n\n");
167            }
168        }
169
170        // Generate client class
171        out.push_str("export class ApiClient {\n");
172        out.push_str("  constructor(private baseUrl = 'http://localhost:8080') {}\n\n");
173        out.push_str("  private async request<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {\n");
174        out.push_str("    const url = new URL(path, this.baseUrl);\n");
175        out.push_str("    if (params) {\n");
176        out.push_str("      for (const [k, v] of Object.entries(params)) {\n");
177        out.push_str("        if (v !== undefined) url.searchParams.set(k, String(v));\n");
178        out.push_str("      }\n");
179        out.push_str("    }\n");
180        out.push_str("    const res = await fetch(url.toString());\n");
181        out.push_str("    if (!res.ok) throw new Error(`HTTP ${res.status}`);\n");
182        out.push_str("    return await res.json() as T;\n");
183        out.push_str("  }\n\n");
184
185        // Generate methods from paths
186        if let Some(paths) = spec.get("paths").and_then(|p| p.as_object()) {
187            for (path, methods) in paths {
188                if let Some(op) = methods.get("get").and_then(|g| g.as_object()) {
189                    let op_id = op
190                        .get("operationId")
191                        .and_then(|id| id.as_str())
192                        .unwrap_or("unknown");
193                    let params = op
194                        .get("parameters")
195                        .and_then(|p| p.as_array())
196                        .map(|a| a.as_slice())
197                        .unwrap_or(&[]);
198
199                    let path_params: Vec<&str> = params
200                        .iter()
201                        .filter(|p| p.get("in").and_then(|i| i.as_str()) == Some("path"))
202                        .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
203                        .collect();
204                    let query_params: Vec<&str> = params
205                        .iter()
206                        .filter(|p| p.get("in").and_then(|i| i.as_str()) == Some("query"))
207                        .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
208                        .collect();
209
210                    // Response type from nested path
211                    let op_value = Value::Object(op.clone());
212                    let resp_type = op_value
213                        .pointer("/responses/200/content/application~1json/schema")
214                        .map(json_schema_to_ts)
215                        .unwrap_or_else(|| "void".to_string());
216
217                    let mut args = Vec::new();
218                    for p in &path_params {
219                        args.push(format!("{}: string", p));
220                    }
221                    if !query_params.is_empty() {
222                        let opts: Vec<String> = query_params
223                            .iter()
224                            .map(|p| format!("{}?: string | number", p))
225                            .collect();
226                        args.push(format!("options?: {{ {} }}", opts.join("; ")));
227                    }
228
229                    let url_template = path.replace('{', "${");
230                    let call_params = if query_params.is_empty() {
231                        ""
232                    } else {
233                        ", options"
234                    };
235
236                    out.push_str(&format!(
237                        "  async {}({}): Promise<{}> {{\n",
238                        op_id,
239                        args.join(", "),
240                        resp_type
241                    ));
242                    out.push_str(&format!(
243                        "    return this.request<{}>(`{}`{});\n",
244                        resp_type, url_template, call_params
245                    ));
246                    out.push_str("  }\n\n");
247                }
248            }
249        }
250
251        out.push_str("}\n");
252        out
253    }
254}
255
256// --- Python (urllib) ---
257
258struct PythonUrllib;
259
260impl OpenApiClientGenerator for PythonUrllib {
261    fn language(&self) -> &'static str {
262        "python"
263    }
264    fn variant(&self) -> &'static str {
265        "urllib"
266    }
267
268    fn generate(&self, spec: &Value) -> String {
269        let mut out = String::new();
270        out.push_str("# Auto-generated from OpenAPI spec\n");
271        out.push_str("# Uses urllib (stdlib)\n\n");
272        out.push_str("from dataclasses import dataclass\n");
273        out.push_str("from typing import Any, Optional\n");
274        out.push_str("from urllib.parse import urlencode\n");
275        out.push_str("from urllib.request import urlopen\n");
276        out.push_str("import json\n\n\n");
277
278        // Generate dataclasses from schemas
279        if let Some(schemas) = spec
280            .pointer("/components/schemas")
281            .and_then(|s| s.as_object())
282        {
283            for (name, schema) in schemas {
284                out.push_str("@dataclass\n");
285                out.push_str(&format!("class {}:\n", name));
286                if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
287                    let required: Vec<&str> = schema
288                        .get("required")
289                        .and_then(|r| r.as_array())
290                        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
291                        .unwrap_or_default();
292
293                    // Required fields first
294                    for (prop_name, prop) in props {
295                        if required.contains(&prop_name.as_str()) {
296                            let py_type = json_schema_to_py(prop);
297                            out.push_str(&format!("    {}: {}\n", prop_name, py_type));
298                        }
299                    }
300                    // Optional fields
301                    for (prop_name, prop) in props {
302                        if !required.contains(&prop_name.as_str()) {
303                            let py_type = json_schema_to_py(prop);
304                            out.push_str(&format!(
305                                "    {}: Optional[{}] = None\n",
306                                prop_name, py_type
307                            ));
308                        }
309                    }
310                    if props.is_empty() {
311                        out.push_str("    pass\n");
312                    }
313                } else {
314                    out.push_str("    pass\n");
315                }
316                out.push_str("\n\n");
317            }
318        }
319
320        // Generate client class
321        out.push_str("class ApiClient:\n");
322        out.push_str("    def __init__(self, base_url: str = 'http://localhost:8080'):\n");
323        out.push_str("        self.base_url = base_url.rstrip('/')\n\n");
324        out.push_str("    def _request(self, path: str, params: Optional[dict] = None) -> dict:\n");
325        out.push_str("        url = f'{self.base_url}{path}'\n");
326        out.push_str("        if params:\n");
327        out.push_str("            filtered = {k: v for k, v in params.items() if v is not None}\n");
328        out.push_str("            if filtered:\n");
329        out.push_str("                url = f'{url}?{urlencode(filtered)}'\n");
330        out.push_str("        with urlopen(url) as response:\n");
331        out.push_str("            return json.load(response)\n\n");
332
333        // Generate methods from paths
334        if let Some(paths) = spec.get("paths").and_then(|p| p.as_object()) {
335            for (path, methods) in paths {
336                if let Some(op) = methods.get("get").and_then(|g| g.as_object()) {
337                    let op_id = op
338                        .get("operationId")
339                        .and_then(|id| id.as_str())
340                        .unwrap_or("unknown");
341                    let params = op
342                        .get("parameters")
343                        .and_then(|p| p.as_array())
344                        .map(|a| a.as_slice())
345                        .unwrap_or(&[]);
346
347                    let path_params: Vec<&str> = params
348                        .iter()
349                        .filter(|p| p.get("in").and_then(|i| i.as_str()) == Some("path"))
350                        .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
351                        .collect();
352                    let query_params: Vec<&str> = params
353                        .iter()
354                        .filter(|p| p.get("in").and_then(|i| i.as_str()) == Some("query"))
355                        .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
356                        .collect();
357
358                    let op_value = Value::Object(op.clone());
359                    let resp_type = op_value
360                        .pointer("/responses/200/content/application~1json/schema")
361                        .map(json_schema_to_py)
362                        .unwrap_or_else(|| "dict".to_string());
363
364                    let mut args = vec!["self".to_string()];
365                    for p in &path_params {
366                        args.push(format!("{}: str", p));
367                    }
368                    if !query_params.is_empty() {
369                        args.push("*".to_string());
370                        for p in &query_params {
371                            args.push(format!("{}: Optional[str] = None", p));
372                        }
373                    }
374
375                    let url_template = path.replace('{', "{");
376                    let params_dict = if query_params.is_empty() {
377                        String::new()
378                    } else {
379                        let kv: Vec<_> = query_params
380                            .iter()
381                            .map(|p| format!("'{}': {}", p, p))
382                            .collect();
383                        format!(", {{{}}}", kv.join(", "))
384                    };
385
386                    out.push_str(&format!(
387                        "    def {}({}) -> {}:\n",
388                        op_id,
389                        args.join(", "),
390                        resp_type
391                    ));
392                    out.push_str(&format!(
393                        "        data = self._request(f'{}'{})\n",
394                        url_template, params_dict
395                    ));
396                    out.push_str(&format!("        return {}(**data)\n\n", resp_type));
397                }
398            }
399        }
400
401        out
402    }
403}
404
405// --- Rust (ureq) ---
406
407struct RustUreq;
408
409impl OpenApiClientGenerator for RustUreq {
410    fn language(&self) -> &'static str {
411        "rust"
412    }
413    fn variant(&self) -> &'static str {
414        "ureq"
415    }
416
417    fn generate(&self, spec: &Value) -> String {
418        let mut out = String::new();
419        out.push_str("//! Auto-generated from OpenAPI spec\n");
420        out.push_str("//! Uses ureq (blocking HTTP)\n\n");
421        out.push_str("use serde::{Deserialize, Serialize};\n\n");
422
423        // Generate structs from schemas
424        if let Some(schemas) = spec
425            .pointer("/components/schemas")
426            .and_then(|s| s.as_object())
427        {
428            for (name, schema) in schemas {
429                out.push_str("#[derive(Debug, Clone, Serialize, Deserialize)]\n");
430                out.push_str(&format!("pub struct {} {{\n", name));
431                if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
432                    let required: Vec<&str> = schema
433                        .get("required")
434                        .and_then(|r| r.as_array())
435                        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
436                        .unwrap_or_default();
437                    for (prop_name, prop) in props {
438                        let rust_type = json_schema_to_rust(prop);
439                        let field_type = if required.contains(&prop_name.as_str()) {
440                            rust_type
441                        } else {
442                            format!("Option<{}>", rust_type)
443                        };
444                        out.push_str(&format!(
445                            "    pub {}: {},\n",
446                            to_snake_case(prop_name),
447                            field_type
448                        ));
449                    }
450                }
451                out.push_str("}\n\n");
452            }
453        }
454
455        // Generate client struct
456        out.push_str("pub struct ApiClient {\n");
457        out.push_str("    base_url: String,\n");
458        out.push_str("}\n\n");
459
460        out.push_str("impl ApiClient {\n");
461        out.push_str("    pub fn new(base_url: impl Into<String>) -> Self {\n");
462        out.push_str("        Self { base_url: base_url.into() }\n");
463        out.push_str("    }\n\n");
464
465        // Generate methods from paths
466        if let Some(paths) = spec.get("paths").and_then(|p| p.as_object()) {
467            for (path, methods) in paths {
468                if let Some(op) = methods.get("get").and_then(|g| g.as_object()) {
469                    let op_id = op
470                        .get("operationId")
471                        .and_then(|id| id.as_str())
472                        .unwrap_or("unknown");
473                    let params = op
474                        .get("parameters")
475                        .and_then(|p| p.as_array())
476                        .map(|a| a.as_slice())
477                        .unwrap_or(&[]);
478
479                    let path_params: Vec<&str> = params
480                        .iter()
481                        .filter(|p| p.get("in").and_then(|i| i.as_str()) == Some("path"))
482                        .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
483                        .collect();
484                    let query_params: Vec<(&str, bool)> = params
485                        .iter()
486                        .filter(|p| p.get("in").and_then(|i| i.as_str()) == Some("query"))
487                        .filter_map(|p| {
488                            let name = p.get("name").and_then(|n| n.as_str())?;
489                            let required =
490                                p.get("required").and_then(|r| r.as_bool()).unwrap_or(false);
491                            Some((name, required))
492                        })
493                        .collect();
494
495                    let op_value = Value::Object(op.clone());
496                    let resp_type = op_value
497                        .pointer("/responses/200/content/application~1json/schema")
498                        .map(json_schema_to_rust)
499                        .unwrap_or_else(|| "()".to_string());
500
501                    // Build function signature
502                    let mut args = Vec::new();
503                    args.push("&self".to_string());
504                    for p in &path_params {
505                        args.push(format!("{}: &str", to_snake_case(p)));
506                    }
507                    for (p, required) in &query_params {
508                        let param_type = if *required {
509                            "&str".to_string()
510                        } else {
511                            "Option<&str>".to_string()
512                        };
513                        args.push(format!("{}: {}", to_snake_case(p), param_type));
514                    }
515
516                    out.push_str(&format!(
517                        "    pub fn {}({}) -> Result<{}, ureq::Error> {{\n",
518                        to_snake_case(op_id),
519                        args.join(", "),
520                        resp_type
521                    ));
522
523                    // Build URL with path params
524                    let url_expr = if path_params.is_empty() {
525                        format!("format!(\"{{}}{}\"", path)
526                    } else {
527                        let rust_path = path_params.iter().fold(path.to_string(), |acc, p| {
528                            acc.replace(&format!("{{{}}}", p), &format!("{{{}}}", to_snake_case(p)))
529                        });
530                        format!("format!(\"{{}}{}\", ", rust_path)
531                    };
532                    out.push_str(&format!("        let url = {}self.base_url);\n", url_expr));
533
534                    // Build request
535                    out.push_str("        let mut req = ureq::get(&url);\n");
536                    for (p, required) in &query_params {
537                        let snake = to_snake_case(p);
538                        if *required {
539                            out.push_str(&format!(
540                                "        req = req.query(\"{}\", {});\n",
541                                p, snake
542                            ));
543                        } else {
544                            out.push_str(&format!(
545                                "        if let Some(v) = {} {{ req = req.query(\"{}\", v); }}\n",
546                                snake, p
547                            ));
548                        }
549                    }
550
551                    out.push_str("        let resp: ");
552                    out.push_str(&resp_type);
553                    out.push_str(" = req.call()?.into_json()?;\n");
554                    out.push_str("        Ok(resp)\n");
555                    out.push_str("    }\n\n");
556                }
557            }
558        }
559
560        out.push_str("}\n");
561        out
562    }
563}
564
565fn to_snake_case(s: &str) -> String {
566    let mut result = String::new();
567    for (i, c) in s.chars().enumerate() {
568        if c.is_uppercase() {
569            if i > 0 {
570                result.push('_');
571            }
572            result.push(c.to_lowercase().next().unwrap());
573        } else {
574            result.push(c);
575        }
576    }
577    result
578}
579
580fn json_schema_to_rust(schema: &Value) -> String {
581    if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
582        return ref_path
583            .split('/')
584            .last()
585            .unwrap_or("serde_json::Value")
586            .to_string();
587    }
588
589    let type_val = schema.get("type");
590
591    if let Some(arr) = type_val.and_then(|t| t.as_array()) {
592        let types: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
593        let non_null: Vec<_> = types.iter().filter(|t| **t != "null").collect();
594        if non_null.len() == 1 {
595            let base = type_str_to_rust(non_null[0]);
596            return format!("Option<{}>", base);
597        }
598    }
599
600    if let Some(type_str) = type_val.and_then(|t| t.as_str()) {
601        if type_str == "array" {
602            if let Some(items) = schema.get("items") {
603                return format!("Vec<{}>", json_schema_to_rust(items));
604            }
605            return "Vec<serde_json::Value>".to_string();
606        }
607        return type_str_to_rust(type_str);
608    }
609
610    "serde_json::Value".to_string()
611}
612
613fn type_str_to_rust(t: &str) -> String {
614    match t {
615        "string" => "String".to_string(),
616        "integer" => "i64".to_string(),
617        "number" => "f64".to_string(),
618        "boolean" => "bool".to_string(),
619        "object" => "serde_json::Value".to_string(),
620        _ => "serde_json::Value".to_string(),
621    }
622}
623
624// --- Helpers ---
625
626fn json_schema_to_ts(schema: &Value) -> String {
627    if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
628        return ref_path.split('/').last().unwrap_or("unknown").to_string();
629    }
630
631    let type_val = schema.get("type");
632
633    if let Some(arr) = type_val.and_then(|t| t.as_array()) {
634        let types: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
635        let non_null: Vec<_> = types.iter().filter(|t| **t != "null").collect();
636        if non_null.len() == 1 {
637            let base = type_str_to_ts(non_null[0]);
638            return format!("{} | null", base);
639        }
640    }
641
642    if let Some(type_str) = type_val.and_then(|t| t.as_str()) {
643        if type_str == "array" {
644            if let Some(items) = schema.get("items") {
645                return format!("{}[]", json_schema_to_ts(items));
646            }
647            return "unknown[]".to_string();
648        }
649        return type_str_to_ts(type_str);
650    }
651
652    "unknown".to_string()
653}
654
655fn type_str_to_ts(t: &str) -> String {
656    match t {
657        "string" => "string".to_string(),
658        "integer" | "number" => "number".to_string(),
659        "boolean" => "boolean".to_string(),
660        "object" => "Record<string, unknown>".to_string(),
661        _ => "unknown".to_string(),
662    }
663}
664
665fn json_schema_to_py(schema: &Value) -> String {
666    if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
667        return ref_path.split('/').last().unwrap_or("Any").to_string();
668    }
669
670    let type_val = schema.get("type");
671
672    if let Some(arr) = type_val.and_then(|t| t.as_array()) {
673        let types: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
674        let non_null: Vec<_> = types.iter().filter(|t| **t != "null").collect();
675        if non_null.len() == 1 {
676            let base = type_str_to_py(non_null[0]);
677            return format!("Optional[{}]", base);
678        }
679    }
680
681    if let Some(type_str) = type_val.and_then(|t| t.as_str()) {
682        if type_str == "array" {
683            if let Some(items) = schema.get("items") {
684                return format!("list[{}]", json_schema_to_py(items));
685            }
686            return "list".to_string();
687        }
688        return type_str_to_py(type_str);
689    }
690
691    "Any".to_string()
692}
693
694fn type_str_to_py(t: &str) -> String {
695    match t {
696        "string" => "str".to_string(),
697        "integer" => "int".to_string(),
698        "number" => "float".to_string(),
699        "boolean" => "bool".to_string(),
700        "object" => "dict".to_string(),
701        _ => "Any".to_string(),
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[test]
710    fn test_find_generator() {
711        assert!(find_generator("typescript").is_some());
712        assert!(find_generator("ts").is_some());
713        assert!(find_generator("python").is_some());
714        assert!(find_generator("py").is_some());
715        assert!(find_generator("rust").is_some());
716        assert!(find_generator("rs").is_some());
717        assert!(find_generator("unknown").is_none());
718    }
719
720    #[test]
721    fn test_list_generators() {
722        let gens = list_generators();
723        assert!(gens.iter().any(|(l, _)| *l == "typescript"));
724        assert!(gens.iter().any(|(l, _)| *l == "python"));
725        assert!(gens.iter().any(|(l, _)| *l == "rust"));
726    }
727
728    #[test]
729    fn test_to_snake_case() {
730        assert_eq!(to_snake_case("getUserById"), "get_user_by_id");
731        assert_eq!(to_snake_case("API"), "a_p_i");
732        assert_eq!(to_snake_case("simple"), "simple");
733    }
734}