Skip to main content

endpoint_gen/
rust.rs

1use crate::definitions::EnumElement;
2use crate::docs::Data;
3use convert_case::{Case, Casing};
4use endpoint_libs::model::{EndpointErrorSchema, EnumVariant, Type};
5use eyre::bail;
6use itertools::Itertools;
7use std::collections::{BTreeSet, HashMap};
8use std::fs::File;
9use std::io::Write;
10use std::path::Path;
11use std::process::Command;
12
13pub trait ToRust {
14    fn to_rust_ref(&self, serde_with: bool) -> String;
15    fn to_rust_decl(&self, serde_with: bool, add_derives: bool) -> String;
16    fn add_derives(&self, input: String) -> String;
17}
18
19impl ToRust for Type {
20    fn to_rust_ref(&self, serde_with: bool) -> String {
21        match self {
22            Type::UInt32 => "u32".to_owned(),
23            Type::Int32 => "i32".to_owned(),
24            Type::Int64 => "i64".to_owned(),
25            Type::Float64 => "f64".to_owned(),
26            Type::TimeStampMs => "i64".to_owned(),
27            Type::Struct { name, .. } => name.clone(),
28            Type::StructRef(name) => name.clone(),
29            Type::Object => "serde_json::Value".to_owned(),
30            // Type::DataTable { name, .. } => format!("Vec<{name}>"),
31            Type::StructTable { struct_ref } => format!("Vec<{struct_ref}>"),
32            Type::Vec(ele) => {
33                format!("Vec<{}>", ele.to_rust_ref(serde_with))
34            }
35            Type::Unit => "()".to_owned(),
36            Type::Optional(t) => {
37                format!("Option<{}>", t.to_rust_ref(serde_with))
38            }
39            Type::Boolean => "bool".to_owned(),
40            Type::String => "String".to_owned(),
41            Type::Bytea => "Vec<u8>".to_owned(),
42            Type::UUID => "Uuid".to_owned(),
43            Type::NanoId { len } => format!("Nanoid<{len}, Base62Alphabet>"),
44            Type::IpAddr => "IpAddr".to_owned(),
45            Type::Enum { name, .. } => format!("Enum{}", name.to_case(Case::Pascal),),
46            Type::EnumRef { name, prefixed_name } => {
47                if *prefixed_name {
48                    format!("Enum{}", name.to_case(Case::Pascal),)
49                } else {
50                    name.to_case(Case::Pascal)
51                }
52            }
53            Type::BlockchainDecimal => "Decimal".to_owned(),
54            Type::BlockchainAddress if serde_with => "Address".to_owned(),
55            Type::BlockchainTransactionHash if serde_with => "H256".to_owned(),
56            Type::BlockchainAddress => "BlockchainAddress".to_owned(),
57            Type::BlockchainTransactionHash => "BlockchainTransactionHash".to_owned(),
58            // `Type` is #[non_exhaustive] as of endpoint-libs 2.0, so a newer libs
59            // release can add variants without breaking this build. Panicking is the
60            // right behaviour: emitting Rust for a type we do not understand would
61            // produce silently wrong generated code.
62            other => panic!(
63                "endpoint-gen does not know how to emit Rust for {other:?}; \
64                 upgrade endpoint-gen to match your endpoint-libs version"
65            ),
66        }
67    }
68
69    fn to_rust_decl(&self, serde_with: bool, add_derives: bool) -> String {
70        let code_regex = regex::Regex::new(r"=\s*(\d+)").expect("Error building regex to extract endpoint code");
71
72        match self {
73            Type::Struct { name, fields } => {
74                let mut fields = fields.iter().map(|x| {
75                    let opt = matches!(&x.ty, Type::Optional(_));
76                    let serde_with_opt = match &x.ty {
77                        Type::BlockchainDecimal => "rust_decimal::serde::str",
78                        Type::BlockchainAddress if serde_with => "WithBlockchainAddress",
79                        Type::BlockchainTransactionHash if serde_with => "WithBlockchainTransactionHash",
80                        // TODO: handle optional decimals
81                        // Type::Optional(t) if matches!(**t, Type::BlockchainDecimal) => {
82                        //     "WithBlockchainDecimal"
83                        // }
84                        // Type::Optional(t) if matches!(**t, Type::BlockchainAddress) => {
85                        //     "WithBlockchainAddress"
86                        // }
87                        // Type::Optional(t) if matches!(**t, Type::BlockchainTransactionHash) => {
88                        //     "WithBlockchainTransactionHash"
89                        // }
90                        _ => "",
91                    };
92                    format!(
93                        "{} {} pub {}: {}",
94                        if opt { "#[serde(default)]" } else { "" },
95                        if serde_with_opt.is_empty() {
96                            "".to_string()
97                        } else {
98                            format!("#[serde(with = \"{serde_with_opt}\")]")
99                        },
100                        x.name,
101                        x.ty.to_rust_ref(serde_with)
102                    )
103                });
104                let input = format!("pub struct {} {{{}}}", name, fields.join(","));
105
106                if add_derives { self.add_derives(input) } else { input }
107            }
108            Type::Enum { name, variants: fields } => {
109                let mut fields = fields
110                    .iter()
111                    .map(|x| {
112                        let variant_name = if x.name.chars().last().unwrap().is_lowercase() {
113                            x.name.to_case(Case::Pascal)
114                        } else {
115                            x.name.clone()
116                        };
117                        // A blank description must omit the `///` line entirely: an
118                        // empty doc comment trips clippy::empty_docs downstream.
119                        if x.description.trim().is_empty() {
120                            format!(
121                                r#"
122    {} = {}
123"#,
124                                variant_name, x.value
125                            )
126                        } else {
127                            format!(
128                                r#"
129    /// {}
130    {} = {}
131"#,
132                                x.description, variant_name, x.value
133                            )
134                        }
135                    })
136                    .sorted_by(|a, b| {
137                        // Sort by the endpoint code
138                        let code_a = {
139                            match code_regex.captures(a) {
140                                Some(code) => code[1].parse::<u64>().unwrap_or_else(|err| {
141                                    eprintln!("Sorting error: {err}: Rust output may not be sorted correctly");
142                                    0
143                                }),
144                                None => {
145                                    eprintln!("Sorting error: Rust output may not be sorted correctly");
146                                    0
147                                }
148                            }
149                        };
150
151                        let code_b = {
152                            match code_regex.captures(b) {
153                                Some(code) => code[1].parse::<u64>().unwrap_or_else(|err| {
154                                    eprintln!("Sorting error: {err}: Rust output may not be sorted correctly");
155                                    0
156                                }),
157                                None => {
158                                    eprintln!("Sorting error: Rust output may not be sorted correctly");
159                                    0
160                                }
161                            }
162                        };
163
164                        code_a.cmp(&code_b)
165                    });
166                let enum_content = format!(
167                    r#"pub enum Enum{} {{{}}}"#,
168                    name.to_case(Case::Pascal),
169                    fields.join(",")
170                );
171
172                if add_derives {
173                    self.add_derives(enum_content)
174                } else {
175                    enum_content
176                }
177            }
178            x => x.to_rust_ref(serde_with),
179        }
180    }
181
182    fn add_derives(&self, input: String) -> String {
183        match self {
184            Self::Enum { .. } => Self::add_default_enum_derives(input),
185            Self::Struct { .. } => Self::add_default_struct_derives(input),
186            _ => input,
187        }
188    }
189}
190
191pub fn collect_rust_recursive_types(t: Type) -> Vec<Type> {
192    match t {
193        Type::Struct { ref fields, .. } => {
194            let mut v = vec![t.clone()];
195            for x in fields {
196                v.extend(collect_rust_recursive_types(x.ty.clone()));
197            }
198            v
199        }
200        // Type::DataTable { name, fields } => {
201        //     collect_rust_recursive_types(Type::struct_(name, fields))
202        // }
203        // Type::StructTable { struct_ref } => {
204        //     collect_rust_recursive_types(Type::struct_ref(struct_ref))
205        // }
206        Type::Vec(x) => collect_rust_recursive_types(*x),
207        Type::Optional(x) => collect_rust_recursive_types(*x),
208        _ => vec![],
209    }
210}
211
212fn endpoint_error_enum_name(endpoint_name: &str) -> String {
213    format!("{}Error", endpoint_name.to_case(Case::Pascal))
214}
215
216fn endpoint_error_variant_name(error: &EndpointErrorSchema) -> String {
217    error.name.to_case(Case::Pascal)
218}
219
220pub(crate) fn error_code_variant_name(name: &str) -> String {
221    name.to_case(Case::Pascal)
222}
223
224fn endpoint_error_code_expr(error: &EndpointErrorSchema) -> String {
225    format!("EnumErrorCode::{}", error_code_variant_name(error.code.variant()))
226}
227
228fn rust_string_literal(value: &str) -> String {
229    serde_json::to_string(value).expect("string serialization should not fail")
230}
231
232fn gen_endpoint_error_enum(
233    endpoint_name: &str,
234    errors: &[EndpointErrorSchema],
235    mut writer: impl Write,
236) -> eyre::Result<()> {
237    if errors.is_empty() {
238        return Ok(());
239    }
240
241    let enum_name = endpoint_error_enum_name(endpoint_name);
242    writeln!(
243        writer,
244        "#[derive(Serialize, Deserialize, Debug, Clone)]\npub enum {enum_name} {{"
245    )?;
246
247    for error in errors {
248        let variant_name = endpoint_error_variant_name(error);
249        if !error.message.is_empty() {
250            writeln!(writer, "    /// {}", error.message)?;
251        }
252        if error.fields.is_empty() {
253            writeln!(writer, "    {variant_name},")?;
254        } else {
255            let fields = error
256                .fields
257                .iter()
258                .map(|field| format!("{}: {}", field.name, field.ty.to_rust_ref(true)))
259                .join(", ");
260            writeln!(writer, "    {variant_name} {{ {fields} }},")?;
261        }
262    }
263
264    writeln!(writer, "}}\n")?;
265    writeln!(writer, "impl From<{enum_name}> for CustomError {{")?;
266    writeln!(writer, "    fn from(err: {enum_name}) -> Self {{")?;
267    writeln!(writer, "        match err {{")?;
268
269    for error in errors {
270        let variant_name = endpoint_error_variant_name(error);
271        let code_expr = endpoint_error_code_expr(error);
272        let message = &error.message;
273        let kind = rust_string_literal(&variant_name);
274        if error.fields.is_empty() {
275            writeln!(
276                writer,
277                "            {enum_name}::{variant_name} => CustomError::new({code_expr}).with_message({}).with_kind({kind}),",
278                rust_string_literal(message),
279            )?;
280        } else {
281            let field_names = error.fields.iter().map(|field| field.name.as_str()).join(", ");
282            let json_fields = error
283                .fields
284                .iter()
285                .map(|field| format!(r#""{}": {}"#, field.name.to_case(Case::Camel), field.name))
286                .join(", ");
287            writeln!(
288                writer,
289                "            {enum_name}::{variant_name} {{ {field_names} }} => CustomError::new({code_expr}).with_message({}).with_kind({kind}).with_details(serde_json::json!({{ {json_fields} }})),",
290                rust_string_literal(message),
291            )?;
292        }
293    }
294
295    writeln!(writer, "        }}")?;
296    writeln!(writer, "    }}")?;
297    writeln!(writer, "}}\n")?;
298
299    Ok(())
300}
301
302pub fn gen_model_rs(data: &Data) -> eyre::Result<()> {
303    let db_filename = data.output_dir.join("model.rs");
304
305    // Ensure the parent directories exist
306    if let Some(parent) = db_filename.parent() {
307        std::fs::create_dir_all(parent)?;
308    }
309
310    let worktable_imports = if data.enums.iter().any(|e| e.config.worktable_support)
311        || data.structs.iter().any(|s| s.config.worktable_support)
312    {
313        r#"use worktable::prelude::*;
314           use rkyv::Archive;
315        "#
316    } else {
317        ""
318    };
319
320    let json_schema_imports = if data.enums.iter().any(|e| e.config.json_schema_gen)
321        || data.structs.iter().any(|s| s.config.json_schema_gen)
322    {
323        r#"use schemars::{schema_for, JsonSchema};"#
324    } else {
325        ""
326    };
327
328    let mut model_file = File::create(&db_filename)?;
329    write!(
330        &mut model_file,
331        "use endpoint_libs::libs::error_code::ErrorCode;
332        use endpoint_libs::libs::ws::*;
333        use endpoint_libs::libs::types::*;
334        use endpoint_libs::libs::ws::toolbox::CustomError;
335        use num_derive::FromPrimitive;
336        use serde::*;
337        use strum_macros::{{Display, EnumString}};
338        use uuid::Uuid;
339        use psc_nanoid::{{Nanoid, alphabet::Base62Alphabet}};
340        use std::net::IpAddr;
341        {worktable_imports}
342        {json_schema_imports}
343        ",
344    )?;
345
346    for e in &data.enums {
347        writeln!(&mut model_file, "{}", e.to_rust_decl(false, true))?;
348    }
349    for s in &data.structs {
350        writeln!(&mut model_file, "{}", s.to_rust_decl(false, true))?;
351    }
352    check_endpoint_codes(data, &mut model_file)?;
353    dump_endpoint_schema(data, &mut model_file)?;
354    dump_type_registry(data, &mut model_file)?;
355
356    let enum_ = Type::enum_(
357        "ErrorCode",
358        data.error_codes
359            .iter()
360            .map(|x| EnumVariant::new_with_description(error_code_variant_name(&x.name), x.description.clone(), x.code))
361            .collect(),
362    );
363    writeln!(&mut model_file, "{}", enum_.to_rust_decl(false, true))?;
364    writeln!(
365        &mut model_file,
366        r#"
367impl From<EnumErrorCode> for ErrorCode {{
368    fn from(e: EnumErrorCode) -> Self {{
369        ErrorCode::new(e as _)
370    }}
371}}
372    "#
373    )?;
374
375    let mut endpoint_reqres_types = BTreeSet::new();
376    for s in &data.services {
377        for e in &s.endpoints {
378            let req = Type::struct_(format!("{}Request", e.schema.name), e.schema.parameters.clone());
379            let resp = Type::struct_(format!("{}Response", e.schema.name), e.schema.returns.clone());
380            endpoint_reqres_types.extend(
381                [
382                    collect_rust_recursive_types(req),
383                    collect_rust_recursive_types(resp),
384                    e.schema
385                        .stream_response
386                        .clone()
387                        .into_iter()
388                        .flat_map(Type::try_unwrap)
389                        .collect::<Vec<_>>(),
390                    e.schema
391                        .errors
392                        .iter()
393                        .flat_map(|error| {
394                            error
395                                .fields
396                                .iter()
397                                .flat_map(|field| collect_rust_recursive_types(field.ty.clone()))
398                        })
399                        .collect::<Vec<_>>(),
400                ]
401                .concat(),
402            );
403        }
404    }
405    for s in endpoint_reqres_types {
406        write!(&mut model_file, r#"{}"#, s.to_rust_decl(true, true))?;
407    }
408
409    for s in &data.services {
410        for endpoint in &s.endpoints {
411            gen_endpoint_error_enum(&endpoint.schema.name, &endpoint.schema.errors, &mut model_file)?;
412        }
413    }
414
415    for s in &data.services {
416        for endpoint in &s.endpoints {
417            let roles_list = resolve_roles_ids(&endpoint.schema.roles, &data.enums)
418                .into_iter()
419                .map(|x| x.to_string())
420                .join(", ");
421
422            write!(
423                &mut model_file,
424                "
425impl WsRequest for {end_name2}Request {{
426    type Response = {end_name2}Response;
427    const METHOD_ID: u32 = {code};
428    const ROLES: &[u32] = &[{roles_list}];
429    const SCHEMA: &'static str = r#\"{schema}\"#;
430}}
431impl WsResponse for {end_name2}Response {{
432    type Request = {end_name2}Request;
433}}
434",
435                end_name2 = endpoint.schema.name.to_case(Case::Pascal),
436                code = endpoint.schema.code,
437                schema = serde_json::to_string_pretty(&endpoint.schema).unwrap()
438            )?;
439        }
440    }
441    model_file.flush()?;
442    drop(model_file);
443    rustfmt(&db_filename)?;
444
445    Ok(())
446}
447
448/// Resolves the IDs of roles from a list of role names and a list of enum types.
449/// endpoint_roles: vec!["Role1::Value1", "Role1::Value2"]
450fn resolve_roles_ids(endpoint_roles: &Vec<String>, all_enums: &Vec<EnumElement>) -> Vec<i64> {
451    let mut all_enums_typed: HashMap<String, Vec<EnumVariant>> = HashMap::new();
452    for e in all_enums {
453        if let Type::Enum { name: _, variants } = &e.inner {
454            all_enums_typed.insert(e.to_rust_ref(false), variants.clone());
455        }
456    }
457
458    let mut roles_ids = vec![];
459    for role in endpoint_roles {
460        let (role_enum_name, role_variant_name) = role.split_once("::").unwrap_or(("", role.as_str()));
461
462        if let Some(role_enum_variants) = all_enums_typed.get(role_enum_name) {
463            if let Some(role_variant_in_endpoint) = role_enum_variants.iter().find(|v| v.name == role_variant_name) {
464                roles_ids.push(role_variant_in_endpoint.value);
465            } else {
466                eprintln!("Warning: Role variant '{role_variant_name}' not found in enum '{role_enum_name}'");
467            }
468        } else {
469            eprintln!("Warning: Role enum '{role_enum_name}' not found");
470        }
471    }
472    // check there is not duplicate roles ids and print error if there are
473    let mut roles_ids_set: BTreeSet<i64> = BTreeSet::new();
474    for id in &roles_ids {
475        if !roles_ids_set.insert(*id) {
476            eprintln!("Warning: Duplicate role ID found: {id}");
477        }
478    }
479
480    roles_ids_set.into_iter().collect()
481}
482
483pub fn rustfmt(f: &Path) -> eyre::Result<()> {
484    let exit = Command::new("rustfmt")
485        .arg("--edition")
486        .arg("2021")
487        .arg(f)
488        .spawn()?
489        .wait()?;
490    if !exit.success() {
491        bail!("failed to rustfmt {:?}", exit);
492    }
493    Ok(())
494}
495
496pub fn check_endpoint_codes(data: &Data, mut writer: impl Write) -> eyre::Result<()> {
497    let mut variants = vec![];
498    for s in &data.services {
499        for e in &s.endpoints {
500            variants.push(EnumVariant::new(e.schema.name.clone(), e.schema.code as _));
501        }
502    }
503    let enum_ = Type::enum_("Endpoint", variants);
504    writeln!(writer, "{}", enum_.to_rust_decl(false, true))?;
505    // if it compiles, there're no duplicate codes or names
506    Ok(())
507}
508pub fn dump_endpoint_schema(data: &Data, mut writer: impl Write) -> eyre::Result<()> {
509    let mut cases = vec![];
510    for s in &data.services {
511        for e in &s.endpoints {
512            cases.push(format!(
513                "Self::{name} => {name}Request::SCHEMA,",
514                name = e.schema.name.to_case(Case::Pascal),
515            ));
516        }
517    }
518    let code = format!(
519        r#"
520    impl EnumEndpoint {{
521        pub fn schema(&self) -> endpoint_libs::model::EndpointSchema {{
522            let schema = match self {{
523                {cases}
524            }};
525            serde_json::from_str(schema).unwrap()
526        }}
527    }}
528    "#,
529        cases = cases.join("\n")
530    );
531    writeln!(writer, "{code}")?;
532    Ok(())
533}
534
535/// Collects every shared type definition (structs, enums, the generated
536/// `ErrorCode` enum) that endpoint schemas may reference by name.
537pub fn shared_type_definitions(data: &Data) -> Vec<Type> {
538    let mut types: Vec<Type> = data.structs.iter().map(|s| s.inner.clone()).collect();
539    types.extend(data.enums.iter().map(|e| e.inner.clone()));
540    types.push(Type::enum_(
541        "ErrorCode",
542        data.error_codes
543            .iter()
544            .map(|x| EnumVariant::new_with_description(error_code_variant_name(&x.name), x.description.clone(), x.code))
545            .collect(),
546    ));
547    types
548}
549
550/// Emits `TYPE_DEFINITIONS` and `type_registry()` into the generated model:
551/// the full list of shared type definitions serialized as JSON (same embedding
552/// pattern as `WsRequest::SCHEMA`), plus a helper that deserializes them into
553/// an `endpoint_libs::model::TypeRegistry` for `WebsocketServer::enable_mcp()`.
554pub fn dump_type_registry(data: &Data, mut writer: impl Write) -> eyre::Result<()> {
555    let types = shared_type_definitions(data);
556    let serialized = serde_json::to_string_pretty(&types)?;
557    let code = format!(
558        r##"
559/// JSON-serialized shared struct/enum definitions referenced by endpoint schemas.
560pub const TYPE_DEFINITIONS: &'static str = r#"{serialized}"#;
561
562/// Builds the type registry over all shared definitions, for use with
563/// `WebsocketServer::enable_mcp()`.
564pub fn type_registry() -> endpoint_libs::model::TypeRegistry {{
565    let types: Vec<endpoint_libs::model::Type> =
566        serde_json::from_str(TYPE_DEFINITIONS).expect("Invalid embedded type definitions");
567    let mut registry = endpoint_libs::model::TypeRegistry::new();
568    registry.add_all(types.iter());
569    registry
570}}
571"##
572    );
573    writeln!(writer, "{code}")?;
574    Ok(())
575}
576
577#[cfg(test)]
578mod tests {
579    use regex::Regex;
580
581    use super::*;
582    use crate::definitions::{EndpointSchemaElement, RustGenConfig};
583    use endpoint_libs::model::{EndpointSchema, Field};
584
585    #[test]
586    fn enum_decl_omits_doc_comment_for_blank_descriptions() {
587        let e = Type::enum_(
588            "sample",
589            vec![
590                EnumVariant::new_with_description("Documented", "Has docs.", 0),
591                EnumVariant::new("Bare", 1),
592                EnumVariant::new_with_description("Blank", "   ", 2),
593            ],
594        );
595        let decl = e.to_rust_decl(false, false);
596        assert!(decl.contains("/// Has docs."));
597        assert!(
598            !decl.lines().any(|l| l.trim() == "///"),
599            "empty doc comment emitted (clippy::empty_docs):\n{decl}"
600        );
601        assert!(decl.contains("Bare = 1"));
602        assert!(decl.contains("Blank = 2"));
603    }
604
605    fn test_data() -> Data {
606        let user_info = Type::struct_(
607            "UserInfo",
608            vec![
609                Field::new("user_id", Type::Int64),
610                Field::new("role", Type::enum_ref("role", true)),
611            ],
612        );
613        let role = Type::enum_("role", vec![EnumVariant::new("Admin", 1)]);
614        Data {
615            project_name: "api.example.com".into(),
616            project_root: std::path::PathBuf::new(),
617            output_dir: std::path::PathBuf::new(),
618            services: vec![crate::definitions::GenService::new(
619                "user".to_string(),
620                1,
621                vec![EndpointSchemaElement {
622                    frontend_facing: true,
623                    config: RustGenConfig::default(),
624                    schema: EndpointSchema::new(
625                        "UserGetProfile",
626                        10010,
627                        vec![Field::new("user_id", Type::Int64)],
628                        vec![Field::new("profile", Type::struct_ref("UserInfo"))],
629                    )
630                    .with_description("Fetches a user profile."),
631                }],
632            )],
633            enums: vec![crate::definitions::EnumElement {
634                config: RustGenConfig::default(),
635                inner: role,
636            }],
637            structs: vec![crate::definitions::StructElement {
638                config: RustGenConfig::default(),
639                inner: user_info,
640            }],
641            error_codes: vec![],
642        }
643    }
644
645    #[test]
646    fn type_registry_dump_round_trips() {
647        let data = test_data();
648        let mut out = Vec::new();
649        dump_type_registry(&data, &mut out).unwrap();
650        let code = String::from_utf8(out).unwrap();
651
652        assert!(code.contains("pub const TYPE_DEFINITIONS"));
653        assert!(code.contains("pub fn type_registry()"));
654
655        // Extract the embedded JSON and round-trip it the way the generated
656        // type_registry() will at runtime.
657        let start = code.find("r#\"").unwrap() + 3;
658        let end = code.find("\"#").unwrap();
659        let types: Vec<Type> = serde_json::from_str(&code[start..end]).unwrap();
660        // UserInfo struct + role enum + ErrorCode enum
661        assert_eq!(types.len(), 3);
662
663        let mut registry = endpoint_libs::model::TypeRegistry::new();
664        registry.add_all(types.iter());
665        assert!(registry.get_struct("UserInfo").is_some());
666        assert!(registry.get_enum("role").is_some());
667        assert!(registry.get_enum("ErrorCode").is_some());
668
669        // The registry must be able to resolve the endpoint's schemas.
670        let schema = &data.services[0].endpoints[0].schema;
671        let input = schema.to_mcp_input_schema(&registry).unwrap();
672        assert_eq!(input["required"], serde_json::json!(["userId"]));
673        let output = schema.to_mcp_output_schema(&registry).unwrap();
674        assert!(output["$defs"]["UserInfo"].is_object());
675    }
676
677    #[test]
678    fn test_extract_number_from_error_code() {
679        let re = Regex::new(r"=\s*(\d+)").unwrap();
680
681        // Test with newline between number and comma
682        let text1 = r#"    ///
683      LoginStep2 = 10003
684  ,"#;
685        let caps1 = re.captures(text1).expect("Should match");
686        let number1: u64 = caps1[1].parse().expect("Should parse as u64");
687        assert_eq!(number1, 10003);
688
689        // Test with spaces but no newline
690        let text2 = "Authorize = 10000,";
691        let caps2 = re.captures(text2).expect("Should match");
692        let number2: u64 = caps2[1].parse().expect("Should parse as u64");
693        assert_eq!(number2, 10000);
694
695        // Test with no spaces
696        let text3 = "SomeError=12345,";
697        let caps3 = re.captures(text3).expect("Should match");
698        let number3: u64 = caps3[1].parse().expect("Should parse as u64");
699        assert_eq!(number3, 12345);
700
701        // Test with multiple spaces
702        let text4 = r#"/// SQL R0019 UnauthorizedMessage
703    UnauthorizedMessage = 45349677
704, "#;
705        let caps4 = re.captures(text4).expect("Should match");
706        let number4: u64 = caps4[1].parse().expect("Should parse as u64");
707        assert_eq!(number4, 45349677);
708    }
709}