Skip to main content

wesley_emit_rust/
lib.rs

1#![deny(warnings)]
2#![deny(missing_docs)]
3
4//! AST-based Rust model emitter for Wesley L1 IR.
5
6use std::collections::BTreeSet;
7use std::fmt::Write;
8use wesley_core::{
9    Field, LawEntryBodyV1, LawIrV1, OperationArgument, OperationType, ScalarRepresentationV1,
10    SchemaOperation, TypeDefinition, TypeKind, TypeReference, WesleyIR,
11};
12
13mod le_binary;
14pub use le_binary::{
15    emit_le_binary_rust, DEFAULT_CODEC_IMPORT as LE_BINARY_RUST_DEFAULT_CODEC_IMPORT,
16    LE_BINARY_GENERATOR_NAME as LE_BINARY_RUST_GENERATOR_NAME,
17};
18
19/// Stable generator identifier recorded in native emit metadata.
20pub const GENERATOR_NAME: &str = "wesley-emit-rust";
21
22/// Version of the Rust emitter crate recorded in native emit metadata.
23pub const GENERATOR_VERSION: &str = env!("CARGO_PKG_VERSION");
24
25/// Emits Rust model declarations for a Wesley L1 IR document.
26pub fn emit_rust(ir: &WesleyIR) -> String {
27    let file = RustFile::from_ir(ir);
28
29    print_file(&file)
30}
31
32/// Emits Rust model and operation binding declarations for a Wesley L1 IR document.
33pub fn emit_rust_with_operations(ir: &WesleyIR, operations: &[SchemaOperation]) -> String {
34    let file = RustFile::from_ir_and_operations(ir, operations);
35
36    print_file(&file)
37}
38
39/// Emits Rust declarations with schema and law hash traceability constants.
40pub fn emit_rust_with_operations_and_hashes(
41    ir: &WesleyIR,
42    operations: &[SchemaOperation],
43    schema_hash: &str,
44    law_hash: &str,
45) -> String {
46    let mut file = RustFile::from_ir_and_operations(ir, operations);
47    file.provenance = Some(RustProvenanceConstants {
48        schema_hash: schema_hash.to_string(),
49        law_hash: law_hash.to_string(),
50    });
51
52    print_file(&file)
53}
54
55/// Emits Rust declarations with provenance constants and law-backed helper validators.
56pub fn emit_rust_with_operations_and_law(
57    ir: &WesleyIR,
58    operations: &[SchemaOperation],
59    schema_hash: &str,
60    law_hash: &str,
61    law_ir: &LawIrV1,
62) -> String {
63    let mut file = RustFile::from_ir_and_operations(ir, operations);
64    file.provenance = Some(RustProvenanceConstants {
65        schema_hash: schema_hash.to_string(),
66        law_hash: law_hash.to_string(),
67    });
68    file.law_items = rust_law_items_from_ir(ir, law_ir);
69
70    print_file(&file)
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74struct RustFile {
75    provenance: Option<RustProvenanceConstants>,
76    items: Vec<RustItem>,
77    law_items: Vec<RustLawItem>,
78}
79
80impl RustFile {
81    fn from_ir(ir: &WesleyIR) -> Self {
82        Self::from_ir_and_operations(ir, &[])
83    }
84
85    fn from_ir_and_operations(ir: &WesleyIR, operations: &[SchemaOperation]) -> Self {
86        let mut items = Vec::new();
87        let root_type_names = operations
88            .iter()
89            .map(|operation| operation.root_type_name.as_str())
90            .collect::<BTreeSet<_>>();
91
92        for type_def in &ir.types {
93            if root_type_names.contains(type_def.name.as_str()) {
94                continue;
95            }
96
97            match type_def.kind {
98                TypeKind::Scalar if is_builtin_scalar(&type_def.name) => {}
99                TypeKind::Scalar => items.push(RustItem::TypeAlias(RustTypeAlias {
100                    name: rust_type_name(&type_def.name),
101                    target: RustType::String,
102                })),
103                TypeKind::Object | TypeKind::Interface | TypeKind::InputObject => {
104                    items.push(RustItem::Struct(struct_from_type(type_def)));
105                }
106                TypeKind::Enum => items.push(RustItem::Enum(enum_from_type(type_def))),
107                TypeKind::Union => items.push(RustItem::Enum(union_enum_from_type(type_def))),
108            }
109        }
110
111        items.extend(
112            operations
113                .iter()
114                .map(|operation| RustItem::Operation(operation_binding_from_schema(operation))),
115        );
116
117        Self {
118            provenance: None,
119            items,
120            law_items: Vec::new(),
121        }
122    }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126struct RustProvenanceConstants {
127    schema_hash: String,
128    law_hash: String,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132enum RustItem {
133    TypeAlias(RustTypeAlias),
134    Struct(RustStruct),
135    Enum(RustEnum),
136    Operation(RustOperationBinding),
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140enum RustLawItem {
141    ScalarValidator(RustScalarValidator),
142    VariantValidator(RustVariantValidator),
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
146struct RustTypeAlias {
147    name: String,
148    target: RustType,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
152struct RustStruct {
153    name: String,
154    fields: Vec<RustField>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158struct RustField {
159    source_name: String,
160    rust_name: String,
161    ty: RustType,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165struct RustEnum {
166    name: String,
167    derive_eq: bool,
168    variants: Vec<RustVariant>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172struct RustOperationBinding {
173    operation_type: &'static str,
174    field_name: String,
175    request: RustStruct,
176    response_alias: RustTypeAlias,
177    directives_json: String,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
181struct RustVariant {
182    source_name: String,
183    rust_name: String,
184    payload: Option<RustType>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188struct RustScalarValidator {
189    function_name: String,
190    scalar_name: String,
191    min_inclusive: Option<u64>,
192    max_inclusive: Option<u64>,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196struct RustVariantValidator {
197    function_name: String,
198    input_type: String,
199    discriminator_field: String,
200    enum_type: String,
201    cases: Vec<RustVariantValidatorCase>,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
205struct RustVariantValidatorCase {
206    enum_variant: String,
207    case_value: String,
208    requires: Vec<String>,
209    forbids: Vec<String>,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
213enum RustType {
214    String,
215    I32,
216    F64,
217    Bool,
218    Named(String),
219    Vec(Box<RustType>),
220    Option(Box<RustType>),
221}
222
223fn rust_law_items_from_ir(ir: &WesleyIR, law_ir: &LawIrV1) -> Vec<RustLawItem> {
224    law_ir
225        .entries
226        .iter()
227        .filter_map(|entry| match &entry.body {
228            LawEntryBodyV1::ScalarSemantics(body) => {
229                scalar_validator_from_law(&entry.subject, body)
230            }
231            LawEntryBodyV1::VariantLaw(body) => {
232                variant_validator_from_law(ir, &entry.subject, body)
233            }
234            LawEntryBodyV1::FootprintLaw(_)
235            | LawEntryBodyV1::ChannelLaw(_)
236            | LawEntryBodyV1::InvariantLaw(_) => None,
237        })
238        .collect()
239}
240
241fn scalar_validator_from_law(
242    subject: &str,
243    body: &wesley_core::ScalarSemanticsLawV1,
244) -> Option<RustLawItem> {
245    if body.representation != ScalarRepresentationV1::Integer {
246        return None;
247    }
248    let scalar_name = subject.strip_prefix("scalar:")?;
249
250    Some(RustLawItem::ScalarValidator(RustScalarValidator {
251        function_name: format!("validate_{}", rust_field_name(scalar_name)),
252        scalar_name: rust_type_name(scalar_name),
253        min_inclusive: body.min_inclusive,
254        max_inclusive: body.max_inclusive,
255    }))
256}
257
258fn variant_validator_from_law(
259    ir: &WesleyIR,
260    subject: &str,
261    body: &wesley_core::VariantLawV1,
262) -> Option<RustLawItem> {
263    let input_name = subject.strip_prefix("input:")?;
264    let input = ir.types.iter().find(|definition| {
265        definition.kind == TypeKind::InputObject && definition.name == input_name
266    })?;
267    let discriminator = input
268        .fields
269        .iter()
270        .find(|field| field.name == body.discriminator.field)?;
271    let cases = body
272        .cases
273        .iter()
274        .map(|case| RustVariantValidatorCase {
275            enum_variant: rust_variant_name(&case.value),
276            case_value: case.value.clone(),
277            requires: dynamic_variant_fields(input, &case.requires),
278            forbids: dynamic_variant_fields(input, &case.forbids),
279        })
280        .collect();
281
282    Some(RustLawItem::VariantValidator(RustVariantValidator {
283        function_name: format!("validate_{}_variant", rust_field_name(input_name)),
284        input_type: rust_type_name(input_name),
285        discriminator_field: rust_field_name(&discriminator.name),
286        enum_type: rust_type_name(&body.discriminator.r#enum),
287        cases,
288    }))
289}
290
291fn dynamic_variant_fields(input: &TypeDefinition, fields: &[String]) -> Vec<String> {
292    fields
293        .iter()
294        .filter_map(|field_name| {
295            let field = input
296                .fields
297                .iter()
298                .find(|field| field.name == *field_name)?;
299            if matches!(rust_type_from_reference(&field.r#type), RustType::Option(_)) {
300                Some(rust_field_name(field_name))
301            } else {
302                None
303            }
304        })
305        .collect()
306}
307
308fn struct_from_type(type_def: &TypeDefinition) -> RustStruct {
309    RustStruct {
310        name: rust_type_name(&type_def.name),
311        fields: type_def.fields.iter().map(field_from_ir).collect(),
312    }
313}
314
315fn field_from_ir(field: &Field) -> RustField {
316    RustField {
317        source_name: field.name.clone(),
318        rust_name: rust_field_name(&field.name),
319        ty: rust_type_from_reference(&field.r#type),
320    }
321}
322
323fn enum_from_type(type_def: &TypeDefinition) -> RustEnum {
324    RustEnum {
325        name: rust_type_name(&type_def.name),
326        derive_eq: true,
327        variants: type_def
328            .enum_values
329            .iter()
330            .map(|value| RustVariant {
331                source_name: value.clone(),
332                rust_name: rust_variant_name(value),
333                payload: None,
334            })
335            .collect(),
336    }
337}
338
339fn union_enum_from_type(type_def: &TypeDefinition) -> RustEnum {
340    RustEnum {
341        name: rust_type_name(&type_def.name),
342        derive_eq: false,
343        variants: type_def
344            .union_members
345            .iter()
346            .map(|member| RustVariant {
347                source_name: member.clone(),
348                rust_name: rust_variant_name(member),
349                payload: Some(RustType::Named(rust_type_name(member))),
350            })
351            .collect(),
352    }
353}
354
355fn operation_binding_from_schema(operation: &SchemaOperation) -> RustOperationBinding {
356    let request_type_name = operation_type_name(operation, "Request");
357    let response_type_name = operation_type_name(operation, "Response");
358
359    RustOperationBinding {
360        operation_type: operation_type_literal(operation.operation_type),
361        field_name: operation.field_name.clone(),
362        request: RustStruct {
363            name: request_type_name,
364            fields: operation
365                .arguments
366                .iter()
367                .map(field_from_operation_argument)
368                .collect(),
369        },
370        response_alias: RustTypeAlias {
371            name: response_type_name,
372            target: rust_type_from_reference(&operation.result_type),
373        },
374        directives_json: serde_json::to_string(&operation.directives)
375            .expect("schema operation directives should serialize"),
376    }
377}
378
379fn field_from_operation_argument(argument: &OperationArgument) -> RustField {
380    let mut ty = rust_type_from_reference(&argument.r#type);
381    if argument.default_value.is_some() && !matches!(ty, RustType::Option(_)) {
382        ty = RustType::Option(Box::new(ty));
383    }
384
385    RustField {
386        source_name: argument.name.clone(),
387        rust_name: rust_field_name(&argument.name),
388        ty,
389    }
390}
391
392fn rust_type_from_reference(type_ref: &TypeReference) -> RustType {
393    let base = match type_ref.base.as_str() {
394        "ID" | "String" => RustType::String,
395        "Int" => RustType::I32,
396        "Float" => RustType::F64,
397        "Boolean" => RustType::Bool,
398        name => RustType::Named(rust_type_name(name)),
399    };
400
401    if !type_ref.list_wrappers.is_empty() {
402        let mut ty = if type_ref.leaf_nullable.unwrap_or(true) {
403            RustType::Option(Box::new(base))
404        } else {
405            base
406        };
407
408        for wrapper in type_ref.list_wrappers.iter().rev() {
409            ty = RustType::Vec(Box::new(ty));
410            if wrapper.nullable {
411                ty = RustType::Option(Box::new(ty));
412            }
413        }
414
415        return ty;
416    }
417
418    let mut ty = if type_ref.is_list {
419        let item = match type_ref.list_item_nullable {
420            Some(true) | None => RustType::Option(Box::new(base)),
421            Some(false) => base,
422        };
423        RustType::Vec(Box::new(item))
424    } else {
425        base
426    };
427
428    if type_ref.nullable {
429        ty = RustType::Option(Box::new(ty));
430    }
431
432    ty
433}
434
435fn print_file(file: &RustFile) -> String {
436    let mut out = String::from("// @generated by Wesley. Do not edit.\n");
437
438    if let Some(provenance) = &file.provenance {
439        out.push('\n');
440        write!(out, "pub const WESLEY_SCHEMA_HASH: &'static str = ")
441            .expect("writing to string should not fail");
442        print_rust_string_literal(&mut out, &provenance.schema_hash);
443        out.push_str(";\n");
444        write!(out, "pub const WESLAW_HASH: &'static str = ")
445            .expect("writing to string should not fail");
446        print_rust_string_literal(&mut out, &provenance.law_hash);
447        out.push_str(";\n");
448    }
449
450    for item in &file.items {
451        out.push('\n');
452        print_item(&mut out, item);
453    }
454
455    for item in &file.law_items {
456        out.push('\n');
457        print_law_item(&mut out, item);
458    }
459
460    out
461}
462
463fn print_item(out: &mut String, item: &RustItem) {
464    match item {
465        RustItem::TypeAlias(alias) => print_type_alias(out, alias),
466        RustItem::Struct(struct_item) => print_struct(out, struct_item),
467        RustItem::Enum(enum_item) => print_enum(out, enum_item),
468        RustItem::Operation(operation) => print_operation_binding(out, operation),
469    }
470}
471
472fn print_law_item(out: &mut String, item: &RustLawItem) {
473    match item {
474        RustLawItem::ScalarValidator(validator) => print_scalar_validator(out, validator),
475        RustLawItem::VariantValidator(validator) => print_variant_validator(out, validator),
476    }
477}
478
479fn print_scalar_validator(out: &mut String, validator: &RustScalarValidator) {
480    writeln!(
481        out,
482        "pub fn {}(value: u64) -> Result<u64, &'static str> {{",
483        validator.function_name
484    )
485    .expect("writing to string should not fail");
486    if let Some(min) = validator.min_inclusive {
487        writeln!(
488            out,
489            "    if value < {min} {{ return Err(\"{} below minInclusive {min}\"); }}",
490            validator.scalar_name
491        )
492        .expect("writing to string should not fail");
493    }
494    if let Some(max) = validator.max_inclusive {
495        writeln!(
496            out,
497            "    if value > {max} {{ return Err(\"{} above maxInclusive {max}\"); }}",
498            validator.scalar_name
499        )
500        .expect("writing to string should not fail");
501    }
502    out.push_str("    Ok(value)\n");
503    out.push_str("}\n");
504}
505
506fn print_variant_validator(out: &mut String, validator: &RustVariantValidator) {
507    writeln!(
508        out,
509        "pub fn {}(value: &{}) -> Result<(), &'static str> {{",
510        validator.function_name, validator.input_type
511    )
512    .expect("writing to string should not fail");
513    writeln!(
514        out,
515        "    match value.{}.clone() {{",
516        validator.discriminator_field
517    )
518    .expect("writing to string should not fail");
519    for case in &validator.cases {
520        writeln!(
521            out,
522            "        {}::{} => {{",
523            validator.enum_type, case.enum_variant
524        )
525        .expect("writing to string should not fail");
526        for field in &case.requires {
527            writeln!(
528                out,
529                "            if value.{field}.is_none() {{ return Err(\"{} requires {field}\"); }}",
530                case.case_value
531            )
532            .expect("writing to string should not fail");
533        }
534        for field in &case.forbids {
535            writeln!(
536                out,
537                "            if value.{field}.is_some() {{ return Err(\"{} forbids {field}\"); }}",
538                case.case_value
539            )
540            .expect("writing to string should not fail");
541        }
542        out.push_str("        }\n");
543    }
544    out.push_str("    }\n");
545    out.push_str("    Ok(())\n");
546    out.push_str("}\n");
547}
548
549fn print_type_alias(out: &mut String, alias: &RustTypeAlias) {
550    write!(out, "pub type {} = ", alias.name).expect("writing to string should not fail");
551    print_type(out, &alias.target);
552    out.push_str(";\n");
553}
554
555fn print_struct(out: &mut String, struct_item: &RustStruct) {
556    out.push_str("#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]\n");
557    writeln!(out, "pub struct {} {{", struct_item.name).expect("writing to string should not fail");
558
559    for field in &struct_item.fields {
560        if field.source_name != field.rust_name.trim_start_matches("r#") {
561            writeln!(
562                out,
563                "    #[serde(rename = \"{}\")]",
564                escape_attribute(&field.source_name)
565            )
566            .expect("writing to string should not fail");
567        }
568        write!(out, "    pub {}: ", field.rust_name).expect("writing to string should not fail");
569        print_type(out, &field.ty);
570        out.push_str(",\n");
571    }
572
573    out.push_str("}\n");
574}
575
576fn print_enum(out: &mut String, enum_item: &RustEnum) {
577    if enum_item.derive_eq {
578        out.push_str(
579            "#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n",
580        );
581    } else {
582        out.push_str("#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]\n");
583    }
584    writeln!(out, "pub enum {} {{", enum_item.name).expect("writing to string should not fail");
585
586    for variant in &enum_item.variants {
587        writeln!(
588            out,
589            "    #[serde(rename = \"{}\")]",
590            escape_attribute(&variant.source_name)
591        )
592        .expect("writing to string should not fail");
593        write!(out, "    {}", variant.rust_name).expect("writing to string should not fail");
594        if let Some(payload) = &variant.payload {
595            out.push('(');
596            print_type(out, payload);
597            out.push(')');
598        }
599        out.push_str(",\n");
600    }
601
602    out.push_str("}\n");
603}
604
605fn print_operation_binding(out: &mut String, operation: &RustOperationBinding) {
606    print_struct(out, &operation.request);
607    out.push('\n');
608    print_type_alias(out, &operation.response_alias);
609    out.push('\n');
610    writeln!(out, "impl {} {{", operation.request.name).expect("writing to string should not fail");
611    write!(out, "    pub const OPERATION_TYPE: &'static str = ")
612        .expect("writing to string should not fail");
613    print_rust_string_literal(out, operation.operation_type);
614    out.push_str(";\n");
615    write!(out, "    pub const FIELD_NAME: &'static str = ")
616        .expect("writing to string should not fail");
617    print_rust_string_literal(out, &operation.field_name);
618    out.push_str(";\n");
619    write!(out, "    pub const DIRECTIVES_JSON: &'static str = ")
620        .expect("writing to string should not fail");
621    print_rust_string_literal(out, &operation.directives_json);
622    out.push_str(";\n");
623    out.push_str("}\n");
624}
625
626fn print_type(out: &mut String, ty: &RustType) {
627    match ty {
628        RustType::String => out.push_str("String"),
629        RustType::I32 => out.push_str("i32"),
630        RustType::F64 => out.push_str("f64"),
631        RustType::Bool => out.push_str("bool"),
632        RustType::Named(name) => out.push_str(name),
633        RustType::Vec(item) => {
634            out.push_str("Vec<");
635            print_type(out, item);
636            out.push('>');
637        }
638        RustType::Option(item) => {
639            out.push_str("Option<");
640            print_type(out, item);
641            out.push('>');
642        }
643    }
644}
645
646fn operation_type_literal(operation_type: OperationType) -> &'static str {
647    match operation_type {
648        OperationType::Query => "QUERY",
649        OperationType::Mutation => "MUTATION",
650        OperationType::Subscription => "SUBSCRIPTION",
651    }
652}
653
654fn is_builtin_scalar(name: &str) -> bool {
655    matches!(name, "ID" | "String" | "Int" | "Float" | "Boolean")
656}
657
658fn rust_type_name(name: &str) -> String {
659    let mut candidate = sanitize_pascal_identifier(name);
660    if candidate.is_empty() {
661        candidate.push_str("GeneratedType");
662    }
663    if candidate
664        .chars()
665        .next()
666        .is_some_and(|ch| ch.is_ascii_digit())
667    {
668        candidate.insert(0, '_');
669    }
670    if is_reserved_word(&candidate) {
671        candidate.push_str("Type");
672    }
673
674    candidate
675}
676
677fn operation_type_name(operation: &SchemaOperation, suffix: &str) -> String {
678    rust_type_name(&format!(
679        "{}{}{suffix}",
680        operation_scope_name(operation.operation_type),
681        rust_type_name(&operation.field_name)
682    ))
683}
684
685fn operation_scope_name(operation_type: OperationType) -> &'static str {
686    match operation_type {
687        OperationType::Query => "Query",
688        OperationType::Mutation => "Mutation",
689        OperationType::Subscription => "Subscription",
690    }
691}
692
693fn rust_variant_name(name: &str) -> String {
694    let mut candidate = if name.contains('_') || name.chars().all(|ch| !ch.is_ascii_lowercase()) {
695        to_pascal_case(name)
696    } else {
697        sanitize_pascal_identifier(name)
698    };
699    if candidate.is_empty() {
700        candidate.push_str("GeneratedVariant");
701    }
702    if candidate
703        .chars()
704        .next()
705        .is_some_and(|ch| ch.is_ascii_digit())
706    {
707        candidate.insert(0, '_');
708    }
709    if is_reserved_word(&candidate) {
710        candidate.push_str("Variant");
711    }
712
713    candidate
714}
715
716fn rust_field_name(name: &str) -> String {
717    let mut candidate = to_snake_case(name);
718    if candidate.is_empty() {
719        candidate.push_str("generated_field");
720    }
721    if candidate
722        .chars()
723        .next()
724        .is_some_and(|ch| ch.is_ascii_digit())
725    {
726        candidate.insert(0, '_');
727    }
728    if is_reserved_word(&candidate) {
729        candidate.insert_str(0, "r#");
730    }
731
732    candidate
733}
734
735fn to_pascal_case(name: &str) -> String {
736    let mut out = String::new();
737    let mut uppercase_next = true;
738
739    for ch in name.chars() {
740        if ch.is_ascii_alphanumeric() {
741            if uppercase_next {
742                out.push(ch.to_ascii_uppercase());
743                uppercase_next = false;
744            } else {
745                out.push(ch.to_ascii_lowercase());
746            }
747        } else {
748            uppercase_next = true;
749        }
750    }
751
752    out
753}
754
755fn sanitize_pascal_identifier(name: &str) -> String {
756    let mut out = String::new();
757
758    for ch in name.chars() {
759        if ch.is_ascii_alphanumeric() || ch == '_' {
760            out.push(ch);
761        } else if !out.ends_with('_') {
762            out.push('_');
763        }
764    }
765
766    let mut chars = out.chars();
767    let Some(first) = chars.next() else {
768        return String::new();
769    };
770
771    if first.is_ascii_alphabetic() || first == '_' {
772        let mut sanitized = String::new();
773        sanitized.push(first.to_ascii_uppercase());
774        sanitized.extend(chars);
775        sanitized.trim_matches('_').to_string()
776    } else {
777        format!("_{}", out.trim_matches('_'))
778    }
779}
780
781fn to_snake_case(name: &str) -> String {
782    let mut out = String::new();
783
784    for (index, ch) in name.chars().enumerate() {
785        if ch.is_ascii_uppercase() {
786            if index > 0 && !out.ends_with('_') {
787                out.push('_');
788            }
789            out.push(ch.to_ascii_lowercase());
790        } else if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
791            out.push(ch);
792        } else if !out.ends_with('_') {
793            out.push('_');
794        }
795    }
796
797    out.trim_matches('_').to_string()
798}
799
800fn is_reserved_word(name: &str) -> bool {
801    matches!(
802        name,
803        "Self"
804            | "abstract"
805            | "as"
806            | "async"
807            | "await"
808            | "become"
809            | "box"
810            | "break"
811            | "const"
812            | "continue"
813            | "crate"
814            | "do"
815            | "dyn"
816            | "else"
817            | "enum"
818            | "extern"
819            | "false"
820            | "final"
821            | "fn"
822            | "for"
823            | "if"
824            | "impl"
825            | "in"
826            | "let"
827            | "loop"
828            | "macro"
829            | "match"
830            | "mod"
831            | "move"
832            | "mut"
833            | "override"
834            | "priv"
835            | "pub"
836            | "ref"
837            | "return"
838            | "self"
839            | "static"
840            | "struct"
841            | "super"
842            | "trait"
843            | "true"
844            | "try"
845            | "type"
846            | "typeof"
847            | "union"
848            | "unsafe"
849            | "unsized"
850            | "use"
851            | "virtual"
852            | "where"
853            | "while"
854            | "yield"
855    )
856}
857
858fn escape_attribute(value: &str) -> String {
859    value.replace('\\', "\\\\").replace('"', "\\\"")
860}
861
862fn print_rust_string_literal(out: &mut String, value: &str) {
863    out.push('"');
864    for ch in value.chars() {
865        match ch {
866            '\\' => out.push_str("\\\\"),
867            '"' => out.push_str("\\\""),
868            '\n' => out.push_str("\\n"),
869            '\r' => out.push_str("\\r"),
870            '\t' => out.push_str("\\t"),
871            _ => out.push(ch),
872        }
873    }
874    out.push('"');
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880    use pretty_assertions::assert_eq;
881    use wesley_core::{list_schema_operations_sdl, load_weslaw_yaml, lower_schema_sdl};
882
883    #[test]
884    fn emits_rust_models_from_l1_ir() {
885        let ir = lower_schema_sdl(
886            r#"
887            scalar DateTime
888
889            enum Role {
890              ADMIN
891              READ_ONLY
892            }
893
894            type User {
895              id: ID!
896              displayName: String
897              createdAt: DateTime!
898              tags: [String]
899            }
900
901            input UserFilter {
902              role: Role
903              limit: Int!
904            }
905            "#,
906        )
907        .expect("schema should lower");
908
909        let actual = emit_rust(&ir);
910
911        syn::parse_file(&actual).expect("generated Rust should parse");
912        assert_eq!(
913            actual,
914            r#"// @generated by Wesley. Do not edit.
915
916pub type DateTime = String;
917
918#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
919pub enum Role {
920    #[serde(rename = "ADMIN")]
921    Admin,
922    #[serde(rename = "READ_ONLY")]
923    ReadOnly,
924}
925
926#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
927pub struct User {
928    pub id: String,
929    #[serde(rename = "displayName")]
930    pub display_name: Option<String>,
931    #[serde(rename = "createdAt")]
932    pub created_at: DateTime,
933    pub tags: Option<Vec<Option<String>>>,
934}
935
936#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
937pub struct UserFilter {
938    pub role: Option<Role>,
939    pub limit: i32,
940}
941"#
942        );
943    }
944
945    #[test]
946    fn emits_nested_graphql_lists_as_nested_rust_vectors() {
947        let ir = lower_schema_sdl(
948            r#"
949            type Matrix {
950              values: [[Int!]!]!
951              maybeValues: [[String]]
952            }
953            "#,
954        )
955        .expect("schema should lower");
956
957        let actual = emit_rust(&ir);
958
959        syn::parse_file(&actual).expect("generated Rust should parse");
960        assert!(actual.contains("pub values: Vec<Vec<i32>>,"));
961        assert!(actual.contains(
962            "#[serde(rename = \"maybeValues\")]\n    pub maybe_values: Option<Vec<Option<Vec<Option<String>>>>>,"
963        ));
964    }
965
966    #[test]
967    fn emits_jedit_shaped_hot_text_fixture() {
968        let ir = lower_schema_sdl(include_str!(
969            "../../../test/fixtures/consumer-models/jedit-hot-text-core.graphql"
970        ))
971        .expect("jedit-shaped fixture should lower");
972
973        let actual = emit_rust(&ir);
974
975        syn::parse_file(&actual).expect("generated Rust should parse");
976        assert!(actual.contains("pub struct BufferWorldline {"));
977        assert!(actual.contains("pub enum AnchorKind {"));
978        assert!(actual.contains("pub checkpoints: Vec<Checkpoint>,"));
979        assert!(actual.contains("pub created_at_tick_id: Option<String>,"));
980        assert!(actual.contains("pub create_initial_checkpoint: Option<bool>,"));
981    }
982
983    #[test]
984    fn emits_jedit_operation_bindings() {
985        let sdl = include_str!("../../../test/fixtures/consumer-models/jedit-rope.graphql");
986        let ir = lower_schema_sdl(sdl).expect("jedit runtime fixture should lower");
987        let operations =
988            list_schema_operations_sdl(sdl).expect("jedit runtime operations should resolve");
989
990        let actual = emit_rust_with_operations(&ir, &operations);
991
992        syn::parse_file(&actual).expect("generated Rust should parse");
993        assert!(!actual.contains("pub struct Mutation {"));
994        assert!(actual.contains("pub struct MutationCreateBufferWorldlineRequest {"));
995        assert!(actual.contains("pub input: CreateBufferWorldlineInput,"));
996        assert!(actual.contains(
997            "pub type MutationCreateBufferWorldlineResponse = CreateBufferWorldlineResult;"
998        ));
999        assert!(actual.contains("pub const OPERATION_TYPE: &'static str = \"MUTATION\";"));
1000        assert!(actual.contains("pub const FIELD_NAME: &'static str = \"createBufferWorldline\";"));
1001        assert!(actual.contains("pub const DIRECTIVES_JSON: &'static str = "));
1002        assert!(actual.contains("\\\"wes_footprint\\\""));
1003    }
1004
1005    #[test]
1006    fn emits_law_backed_scalar_and_variant_validators() {
1007        let sdl = include_str!("../../../test/fixtures/weslaw/contract-bundle-shape.graphql");
1008        let law_ir = load_weslaw_yaml(include_str!(
1009            "../../../test/fixtures/weslaw/accepted/rust-validator-payoff.weslaw.yaml"
1010        ))
1011        .expect("law fixture should lower");
1012        let ir = lower_schema_sdl(sdl).expect("schema should lower");
1013        let operations = list_schema_operations_sdl(sdl).expect("operations should resolve");
1014
1015        let actual = emit_rust_with_operations_and_law(
1016            &ir,
1017            &operations,
1018            "sha256:schema",
1019            "sha256:law",
1020            &law_ir,
1021        );
1022
1023        syn::parse_file(&actual).expect("generated Rust should parse");
1024        assert!(actual.contains("pub fn validate_positive_int(value: u64)"));
1025        assert!(actual.contains("if value < 1"));
1026        assert!(actual.contains("if value > 4294967295"));
1027        assert!(actual
1028            .contains("pub fn validate_playback_mode_input_variant(value: &PlaybackModeInput)"));
1029        assert!(actual.contains("PlaybackModeKind::Seek => {"));
1030        assert!(actual.contains("if value.target.is_none()"));
1031        assert!(actual.contains("if value.then.is_none()"));
1032        assert!(actual.contains("PlaybackModeKind::Paused => {"));
1033        assert!(actual.contains("if value.target.is_some()"));
1034    }
1035
1036    #[test]
1037    fn emits_stack_witness_0001_fixture_operation_bindings() {
1038        let sdl = include_str!(
1039            "../../../test/fixtures/consumer-models/stack-witness-0001-file-history.graphql"
1040        );
1041        let ir = lower_schema_sdl(sdl).expect("stack witness fixture should lower");
1042        let operations =
1043            list_schema_operations_sdl(sdl).expect("stack witness operations should resolve");
1044
1045        let actual = emit_rust_with_operations(&ir, &operations);
1046
1047        syn::parse_file(&actual).expect("generated Rust should parse");
1048        assert!(actual.contains("pub struct MutationCreateBufferRequest {"));
1049        assert!(actual.contains("pub input: CreateBufferInput,"));
1050        assert!(actual.contains("pub type MutationCreateBufferResponse = MutationReceipt;"));
1051        assert!(actual.contains("pub struct MutationReplaceRangeRequest {"));
1052        assert!(actual.contains("pub type MutationReplaceRangeResponse = MutationReceipt;"));
1053        assert!(actual.contains("pub struct QueryTextWindowRequest {"));
1054        assert!(actual.contains("pub type QueryTextWindowResponse = TextWindowReading;"));
1055        assert!(actual.contains("pub data_base64: String,"));
1056
1057        let create_buffer = rust_operation_impl_block(&actual, "MutationCreateBufferRequest");
1058        assert!(create_buffer.contains("pub const FIELD_NAME: &'static str = \"createBuffer\";"));
1059        assert!(create_buffer.contains("\\\"artifactId\\\":\\\"fixture-file-history-v0\\\""));
1060        assert!(create_buffer.contains("\\\"opId\\\":\\\"0x53570001\\\""));
1061        assert!(create_buffer.contains("\\\"helperKind\\\":\\\"EINT\\\""));
1062        assert!(create_buffer.contains("\\\"targetCodec\\\":\\\"wesley-binary/v0\\\""));
1063        assert!(create_buffer.contains(
1064            "\\\"fixtureVarsBytes\\\":\\\"stack-witness-0001/createBuffer;name=demo.txt;artifact=fixture-file-history-v0\\\""
1065        ));
1066
1067        let replace_range = rust_operation_impl_block(&actual, "MutationReplaceRangeRequest");
1068        assert!(replace_range.contains("pub const FIELD_NAME: &'static str = \"replaceRange\";"));
1069        assert!(replace_range.contains("\\\"artifactId\\\":\\\"fixture-file-history-v0\\\""));
1070        assert!(replace_range.contains("\\\"opId\\\":\\\"0x53570002\\\""));
1071        assert!(replace_range.contains("\\\"helperKind\\\":\\\"EINT\\\""));
1072        assert!(replace_range.contains("\\\"targetCodec\\\":\\\"wesley-binary/v0\\\""));
1073        assert!(replace_range.contains(
1074            "\\\"fixtureVarsBytes\\\":\\\"stack-witness-0001/replaceRange;bufferId=demo.txt;basis=B0;coord=utf8-bytes;start=0;end=0;text=hello;artifact=fixture-file-history-v0\\\""
1075        ));
1076
1077        let text_window = rust_operation_impl_block(&actual, "QueryTextWindowRequest");
1078        assert!(text_window.contains("pub const FIELD_NAME: &'static str = \"textWindow\";"));
1079        assert!(text_window.contains("\\\"artifactId\\\":\\\"fixture-file-history-v0\\\""));
1080        assert!(text_window.contains("\\\"opId\\\":\\\"0x53571001\\\""));
1081        assert!(text_window.contains("\\\"helperKind\\\":\\\"QueryView\\\""));
1082        assert!(text_window.contains("\\\"targetCodec\\\":\\\"wesley-binary/v0\\\""));
1083        assert!(text_window.contains(
1084            "\\\"fixtureVarsBytes\\\":\\\"stack-witness-0001/textWindow;bufferId=demo.txt;basis=B1;coord=utf8-bytes;start=0;length=5;artifact=fixture-file-history-v0\\\""
1085        ));
1086        assert!(text_window.contains("\\\"payloadCodec\\\":\\\"QueryBytes\\\""));
1087        assert!(text_window.contains("\\\"envelope\\\":\\\"ReadingEnvelope\\\""));
1088    }
1089
1090    #[test]
1091    fn emits_domain_empty_fixture_without_product_nouns() {
1092        let sdl = include_str!("../../../test/fixtures/rust-emitter/domain-empty.graphql");
1093        let ir = lower_schema_sdl(sdl).expect("domain-empty fixture should lower");
1094        let operations = list_schema_operations_sdl(sdl)
1095            .expect("domain-empty fixture operations should resolve");
1096
1097        let actual = emit_rust_with_operations(&ir, &operations);
1098
1099        syn::parse_file(&actual).expect("generated Rust should parse");
1100        assert!(actual.contains("pub struct ContractRecord {"));
1101        assert!(actual.contains("pub struct MutationPublishContractRequest {"));
1102        assert!(actual.contains("pub type QueryContractResponse = Option<ContractRecord>;"));
1103        for forbidden in ["Postgres", "Supabase", "Echo", "jedit", "Jedit"] {
1104            assert!(
1105                !actual.contains(forbidden),
1106                "generic Rust output leaked product noun `{forbidden}`"
1107            );
1108        }
1109    }
1110
1111    #[test]
1112    fn operation_bindings_include_operation_scope_in_type_names() {
1113        let sdl = r#"
1114            type Query {
1115              status: Status!
1116            }
1117
1118            type Mutation {
1119              status(input: StatusInput!): Status!
1120            }
1121
1122            type Status {
1123              ok: Boolean!
1124            }
1125
1126            input StatusInput {
1127              reason: String
1128            }
1129        "#;
1130        let ir = lower_schema_sdl(sdl).expect("schema should lower");
1131        let operations = list_schema_operations_sdl(sdl).expect("operations should resolve");
1132
1133        let actual = emit_rust_with_operations(&ir, &operations);
1134
1135        syn::parse_file(&actual).expect("generated Rust should parse");
1136        assert!(actual.contains("pub struct QueryStatusRequest {"));
1137        assert!(actual.contains("pub type QueryStatusResponse = Status;"));
1138        assert!(actual.contains("pub struct MutationStatusRequest {"));
1139        assert!(actual.contains("pub type MutationStatusResponse = Status;"));
1140    }
1141
1142    #[test]
1143    fn sanitizes_reserved_field_names() {
1144        assert_eq!(rust_field_name("type"), "r#type");
1145        assert_eq!(rust_field_name("displayName"), "display_name");
1146    }
1147
1148    #[test]
1149    fn union_payload_enums_do_not_derive_eq() {
1150        let ir = lower_schema_sdl(
1151            r#"
1152            type User {
1153              id: ID!
1154            }
1155
1156            union SearchResult = User
1157            "#,
1158        )
1159        .expect("schema should lower");
1160
1161        let actual = emit_rust(&ir);
1162
1163        syn::parse_file(&actual).expect("generated Rust should parse");
1164        assert!(actual.contains(
1165            "#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]\npub enum SearchResult"
1166        ));
1167    }
1168
1169    fn rust_operation_impl_block<'a>(actual: &'a str, request_name: &str) -> &'a str {
1170        let marker = format!("impl {request_name} {{");
1171        let start = actual
1172            .find(&marker)
1173            .expect("operation impl block should exist");
1174        let tail = &actual[start..];
1175        let end = tail
1176            .find("\n}\n")
1177            .expect("operation impl block should close")
1178            + "\n}\n".len();
1179        &tail[..end]
1180    }
1181}