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, OperationArgument, OperationType, SchemaOperation, TypeDefinition, TypeKind,
10    TypeReference, WesleyIR,
11};
12
13/// Emits Rust model declarations for a Wesley L1 IR document.
14pub fn emit_rust(ir: &WesleyIR) -> String {
15    let file = RustFile::from_ir(ir);
16
17    print_file(&file)
18}
19
20/// Emits Rust model and operation binding declarations for a Wesley L1 IR document.
21pub fn emit_rust_with_operations(ir: &WesleyIR, operations: &[SchemaOperation]) -> String {
22    let file = RustFile::from_ir_and_operations(ir, operations);
23
24    print_file(&file)
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28struct RustFile {
29    items: Vec<RustItem>,
30}
31
32impl RustFile {
33    fn from_ir(ir: &WesleyIR) -> Self {
34        Self::from_ir_and_operations(ir, &[])
35    }
36
37    fn from_ir_and_operations(ir: &WesleyIR, operations: &[SchemaOperation]) -> Self {
38        let mut items = Vec::new();
39        let root_type_names = operations
40            .iter()
41            .map(|operation| operation.root_type_name.as_str())
42            .collect::<BTreeSet<_>>();
43
44        for type_def in &ir.types {
45            if root_type_names.contains(type_def.name.as_str()) {
46                continue;
47            }
48
49            match type_def.kind {
50                TypeKind::Scalar if is_builtin_scalar(&type_def.name) => {}
51                TypeKind::Scalar => items.push(RustItem::TypeAlias(RustTypeAlias {
52                    name: rust_type_name(&type_def.name),
53                    target: RustType::String,
54                })),
55                TypeKind::Object | TypeKind::Interface | TypeKind::InputObject => {
56                    items.push(RustItem::Struct(struct_from_type(type_def)));
57                }
58                TypeKind::Enum => items.push(RustItem::Enum(enum_from_type(type_def))),
59                TypeKind::Union => items.push(RustItem::Enum(union_enum_from_type(type_def))),
60            }
61        }
62
63        items.extend(
64            operations
65                .iter()
66                .map(|operation| RustItem::Operation(operation_binding_from_schema(operation))),
67        );
68
69        Self { items }
70    }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74enum RustItem {
75    TypeAlias(RustTypeAlias),
76    Struct(RustStruct),
77    Enum(RustEnum),
78    Operation(RustOperationBinding),
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82struct RustTypeAlias {
83    name: String,
84    target: RustType,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88struct RustStruct {
89    name: String,
90    fields: Vec<RustField>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94struct RustField {
95    source_name: String,
96    rust_name: String,
97    ty: RustType,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101struct RustEnum {
102    name: String,
103    derive_eq: bool,
104    variants: Vec<RustVariant>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108struct RustOperationBinding {
109    operation_type: &'static str,
110    field_name: String,
111    request: RustStruct,
112    response_alias: RustTypeAlias,
113    directives_json: String,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117struct RustVariant {
118    source_name: String,
119    rust_name: String,
120    payload: Option<RustType>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124enum RustType {
125    String,
126    I32,
127    F64,
128    Bool,
129    Named(String),
130    Vec(Box<RustType>),
131    Option(Box<RustType>),
132}
133
134fn struct_from_type(type_def: &TypeDefinition) -> RustStruct {
135    RustStruct {
136        name: rust_type_name(&type_def.name),
137        fields: type_def.fields.iter().map(field_from_ir).collect(),
138    }
139}
140
141fn field_from_ir(field: &Field) -> RustField {
142    RustField {
143        source_name: field.name.clone(),
144        rust_name: rust_field_name(&field.name),
145        ty: rust_type_from_reference(&field.r#type),
146    }
147}
148
149fn enum_from_type(type_def: &TypeDefinition) -> RustEnum {
150    RustEnum {
151        name: rust_type_name(&type_def.name),
152        derive_eq: true,
153        variants: type_def
154            .enum_values
155            .iter()
156            .map(|value| RustVariant {
157                source_name: value.clone(),
158                rust_name: rust_variant_name(value),
159                payload: None,
160            })
161            .collect(),
162    }
163}
164
165fn union_enum_from_type(type_def: &TypeDefinition) -> RustEnum {
166    RustEnum {
167        name: rust_type_name(&type_def.name),
168        derive_eq: false,
169        variants: type_def
170            .union_members
171            .iter()
172            .map(|member| RustVariant {
173                source_name: member.clone(),
174                rust_name: rust_variant_name(member),
175                payload: Some(RustType::Named(rust_type_name(member))),
176            })
177            .collect(),
178    }
179}
180
181fn operation_binding_from_schema(operation: &SchemaOperation) -> RustOperationBinding {
182    let request_type_name = operation_type_name(operation, "Request");
183    let response_type_name = operation_type_name(operation, "Response");
184
185    RustOperationBinding {
186        operation_type: operation_type_literal(operation.operation_type),
187        field_name: operation.field_name.clone(),
188        request: RustStruct {
189            name: request_type_name,
190            fields: operation
191                .arguments
192                .iter()
193                .map(field_from_operation_argument)
194                .collect(),
195        },
196        response_alias: RustTypeAlias {
197            name: response_type_name,
198            target: rust_type_from_reference(&operation.result_type),
199        },
200        directives_json: serde_json::to_string(&operation.directives)
201            .expect("schema operation directives should serialize"),
202    }
203}
204
205fn field_from_operation_argument(argument: &OperationArgument) -> RustField {
206    let mut ty = rust_type_from_reference(&argument.r#type);
207    if argument.default_value.is_some() && !matches!(ty, RustType::Option(_)) {
208        ty = RustType::Option(Box::new(ty));
209    }
210
211    RustField {
212        source_name: argument.name.clone(),
213        rust_name: rust_field_name(&argument.name),
214        ty,
215    }
216}
217
218fn rust_type_from_reference(type_ref: &TypeReference) -> RustType {
219    let base = match type_ref.base.as_str() {
220        "ID" | "String" => RustType::String,
221        "Int" => RustType::I32,
222        "Float" => RustType::F64,
223        "Boolean" => RustType::Bool,
224        name => RustType::Named(rust_type_name(name)),
225    };
226
227    if !type_ref.list_wrappers.is_empty() {
228        let mut ty = if type_ref.leaf_nullable.unwrap_or(true) {
229            RustType::Option(Box::new(base))
230        } else {
231            base
232        };
233
234        for wrapper in type_ref.list_wrappers.iter().rev() {
235            ty = RustType::Vec(Box::new(ty));
236            if wrapper.nullable {
237                ty = RustType::Option(Box::new(ty));
238            }
239        }
240
241        return ty;
242    }
243
244    let mut ty = if type_ref.is_list {
245        let item = match type_ref.list_item_nullable {
246            Some(true) | None => RustType::Option(Box::new(base)),
247            Some(false) => base,
248        };
249        RustType::Vec(Box::new(item))
250    } else {
251        base
252    };
253
254    if type_ref.nullable {
255        ty = RustType::Option(Box::new(ty));
256    }
257
258    ty
259}
260
261fn print_file(file: &RustFile) -> String {
262    let mut out = String::from("// @generated by Wesley. Do not edit.\n");
263
264    for item in &file.items {
265        out.push('\n');
266        print_item(&mut out, item);
267    }
268
269    out
270}
271
272fn print_item(out: &mut String, item: &RustItem) {
273    match item {
274        RustItem::TypeAlias(alias) => print_type_alias(out, alias),
275        RustItem::Struct(struct_item) => print_struct(out, struct_item),
276        RustItem::Enum(enum_item) => print_enum(out, enum_item),
277        RustItem::Operation(operation) => print_operation_binding(out, operation),
278    }
279}
280
281fn print_type_alias(out: &mut String, alias: &RustTypeAlias) {
282    write!(out, "pub type {} = ", alias.name).expect("writing to string should not fail");
283    print_type(out, &alias.target);
284    out.push_str(";\n");
285}
286
287fn print_struct(out: &mut String, struct_item: &RustStruct) {
288    out.push_str("#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]\n");
289    writeln!(out, "pub struct {} {{", struct_item.name).expect("writing to string should not fail");
290
291    for field in &struct_item.fields {
292        if field.source_name != field.rust_name.trim_start_matches("r#") {
293            writeln!(
294                out,
295                "    #[serde(rename = \"{}\")]",
296                escape_attribute(&field.source_name)
297            )
298            .expect("writing to string should not fail");
299        }
300        write!(out, "    pub {}: ", field.rust_name).expect("writing to string should not fail");
301        print_type(out, &field.ty);
302        out.push_str(",\n");
303    }
304
305    out.push_str("}\n");
306}
307
308fn print_enum(out: &mut String, enum_item: &RustEnum) {
309    if enum_item.derive_eq {
310        out.push_str(
311            "#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n",
312        );
313    } else {
314        out.push_str("#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]\n");
315    }
316    writeln!(out, "pub enum {} {{", enum_item.name).expect("writing to string should not fail");
317
318    for variant in &enum_item.variants {
319        writeln!(
320            out,
321            "    #[serde(rename = \"{}\")]",
322            escape_attribute(&variant.source_name)
323        )
324        .expect("writing to string should not fail");
325        write!(out, "    {}", variant.rust_name).expect("writing to string should not fail");
326        if let Some(payload) = &variant.payload {
327            out.push('(');
328            print_type(out, payload);
329            out.push(')');
330        }
331        out.push_str(",\n");
332    }
333
334    out.push_str("}\n");
335}
336
337fn print_operation_binding(out: &mut String, operation: &RustOperationBinding) {
338    print_struct(out, &operation.request);
339    out.push('\n');
340    print_type_alias(out, &operation.response_alias);
341    out.push('\n');
342    writeln!(out, "impl {} {{", operation.request.name).expect("writing to string should not fail");
343    write!(out, "    pub const OPERATION_TYPE: &'static str = ")
344        .expect("writing to string should not fail");
345    print_rust_string_literal(out, operation.operation_type);
346    out.push_str(";\n");
347    write!(out, "    pub const FIELD_NAME: &'static str = ")
348        .expect("writing to string should not fail");
349    print_rust_string_literal(out, &operation.field_name);
350    out.push_str(";\n");
351    write!(out, "    pub const DIRECTIVES_JSON: &'static str = ")
352        .expect("writing to string should not fail");
353    print_rust_string_literal(out, &operation.directives_json);
354    out.push_str(";\n");
355    out.push_str("}\n");
356}
357
358fn print_type(out: &mut String, ty: &RustType) {
359    match ty {
360        RustType::String => out.push_str("String"),
361        RustType::I32 => out.push_str("i32"),
362        RustType::F64 => out.push_str("f64"),
363        RustType::Bool => out.push_str("bool"),
364        RustType::Named(name) => out.push_str(name),
365        RustType::Vec(item) => {
366            out.push_str("Vec<");
367            print_type(out, item);
368            out.push('>');
369        }
370        RustType::Option(item) => {
371            out.push_str("Option<");
372            print_type(out, item);
373            out.push('>');
374        }
375    }
376}
377
378fn operation_type_literal(operation_type: OperationType) -> &'static str {
379    match operation_type {
380        OperationType::Query => "QUERY",
381        OperationType::Mutation => "MUTATION",
382        OperationType::Subscription => "SUBSCRIPTION",
383    }
384}
385
386fn is_builtin_scalar(name: &str) -> bool {
387    matches!(name, "ID" | "String" | "Int" | "Float" | "Boolean")
388}
389
390fn rust_type_name(name: &str) -> String {
391    let mut candidate = sanitize_pascal_identifier(name);
392    if candidate.is_empty() {
393        candidate.push_str("GeneratedType");
394    }
395    if candidate
396        .chars()
397        .next()
398        .is_some_and(|ch| ch.is_ascii_digit())
399    {
400        candidate.insert(0, '_');
401    }
402    if is_reserved_word(&candidate) {
403        candidate.push_str("Type");
404    }
405
406    candidate
407}
408
409fn operation_type_name(operation: &SchemaOperation, suffix: &str) -> String {
410    rust_type_name(&format!(
411        "{}{}{suffix}",
412        operation_scope_name(operation.operation_type),
413        rust_type_name(&operation.field_name)
414    ))
415}
416
417fn operation_scope_name(operation_type: OperationType) -> &'static str {
418    match operation_type {
419        OperationType::Query => "Query",
420        OperationType::Mutation => "Mutation",
421        OperationType::Subscription => "Subscription",
422    }
423}
424
425fn rust_variant_name(name: &str) -> String {
426    let mut candidate = if name.contains('_') || name.chars().all(|ch| !ch.is_ascii_lowercase()) {
427        to_pascal_case(name)
428    } else {
429        sanitize_pascal_identifier(name)
430    };
431    if candidate.is_empty() {
432        candidate.push_str("GeneratedVariant");
433    }
434    if candidate
435        .chars()
436        .next()
437        .is_some_and(|ch| ch.is_ascii_digit())
438    {
439        candidate.insert(0, '_');
440    }
441    if is_reserved_word(&candidate) {
442        candidate.push_str("Variant");
443    }
444
445    candidate
446}
447
448fn rust_field_name(name: &str) -> String {
449    let mut candidate = to_snake_case(name);
450    if candidate.is_empty() {
451        candidate.push_str("generated_field");
452    }
453    if candidate
454        .chars()
455        .next()
456        .is_some_and(|ch| ch.is_ascii_digit())
457    {
458        candidate.insert(0, '_');
459    }
460    if is_reserved_word(&candidate) {
461        candidate.insert_str(0, "r#");
462    }
463
464    candidate
465}
466
467fn to_pascal_case(name: &str) -> String {
468    let mut out = String::new();
469    let mut uppercase_next = true;
470
471    for ch in name.chars() {
472        if ch.is_ascii_alphanumeric() {
473            if uppercase_next {
474                out.push(ch.to_ascii_uppercase());
475                uppercase_next = false;
476            } else {
477                out.push(ch.to_ascii_lowercase());
478            }
479        } else {
480            uppercase_next = true;
481        }
482    }
483
484    out
485}
486
487fn sanitize_pascal_identifier(name: &str) -> String {
488    let mut out = String::new();
489
490    for ch in name.chars() {
491        if ch.is_ascii_alphanumeric() || ch == '_' {
492            out.push(ch);
493        } else if !out.ends_with('_') {
494            out.push('_');
495        }
496    }
497
498    let mut chars = out.chars();
499    let Some(first) = chars.next() else {
500        return String::new();
501    };
502
503    if first.is_ascii_alphabetic() || first == '_' {
504        let mut sanitized = String::new();
505        sanitized.push(first.to_ascii_uppercase());
506        sanitized.extend(chars);
507        sanitized.trim_matches('_').to_string()
508    } else {
509        format!("_{}", out.trim_matches('_'))
510    }
511}
512
513fn to_snake_case(name: &str) -> String {
514    let mut out = String::new();
515
516    for (index, ch) in name.chars().enumerate() {
517        if ch.is_ascii_uppercase() {
518            if index > 0 && !out.ends_with('_') {
519                out.push('_');
520            }
521            out.push(ch.to_ascii_lowercase());
522        } else if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
523            out.push(ch);
524        } else if !out.ends_with('_') {
525            out.push('_');
526        }
527    }
528
529    out.trim_matches('_').to_string()
530}
531
532fn is_reserved_word(name: &str) -> bool {
533    matches!(
534        name,
535        "Self"
536            | "abstract"
537            | "as"
538            | "async"
539            | "await"
540            | "become"
541            | "box"
542            | "break"
543            | "const"
544            | "continue"
545            | "crate"
546            | "do"
547            | "dyn"
548            | "else"
549            | "enum"
550            | "extern"
551            | "false"
552            | "final"
553            | "fn"
554            | "for"
555            | "if"
556            | "impl"
557            | "in"
558            | "let"
559            | "loop"
560            | "macro"
561            | "match"
562            | "mod"
563            | "move"
564            | "mut"
565            | "override"
566            | "priv"
567            | "pub"
568            | "ref"
569            | "return"
570            | "self"
571            | "static"
572            | "struct"
573            | "super"
574            | "trait"
575            | "true"
576            | "try"
577            | "type"
578            | "typeof"
579            | "union"
580            | "unsafe"
581            | "unsized"
582            | "use"
583            | "virtual"
584            | "where"
585            | "while"
586            | "yield"
587    )
588}
589
590fn escape_attribute(value: &str) -> String {
591    value.replace('\\', "\\\\").replace('"', "\\\"")
592}
593
594fn print_rust_string_literal(out: &mut String, value: &str) {
595    out.push('"');
596    for ch in value.chars() {
597        match ch {
598            '\\' => out.push_str("\\\\"),
599            '"' => out.push_str("\\\""),
600            '\n' => out.push_str("\\n"),
601            '\r' => out.push_str("\\r"),
602            '\t' => out.push_str("\\t"),
603            _ => out.push(ch),
604        }
605    }
606    out.push('"');
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612    use pretty_assertions::assert_eq;
613    use wesley_core::{list_schema_operations_sdl, lower_schema_sdl};
614
615    #[test]
616    fn emits_rust_models_from_l1_ir() {
617        let ir = lower_schema_sdl(
618            r#"
619            scalar DateTime
620
621            enum Role {
622              ADMIN
623              READ_ONLY
624            }
625
626            type User {
627              id: ID!
628              displayName: String
629              createdAt: DateTime!
630              tags: [String]
631            }
632
633            input UserFilter {
634              role: Role
635              limit: Int!
636            }
637            "#,
638        )
639        .expect("schema should lower");
640
641        let actual = emit_rust(&ir);
642
643        syn::parse_file(&actual).expect("generated Rust should parse");
644        assert_eq!(
645            actual,
646            r#"// @generated by Wesley. Do not edit.
647
648pub type DateTime = String;
649
650#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
651pub enum Role {
652    #[serde(rename = "ADMIN")]
653    Admin,
654    #[serde(rename = "READ_ONLY")]
655    ReadOnly,
656}
657
658#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
659pub struct User {
660    pub id: String,
661    #[serde(rename = "displayName")]
662    pub display_name: Option<String>,
663    #[serde(rename = "createdAt")]
664    pub created_at: DateTime,
665    pub tags: Option<Vec<Option<String>>>,
666}
667
668#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
669pub struct UserFilter {
670    pub role: Option<Role>,
671    pub limit: i32,
672}
673"#
674        );
675    }
676
677    #[test]
678    fn emits_nested_graphql_lists_as_nested_rust_vectors() {
679        let ir = lower_schema_sdl(
680            r#"
681            type Matrix {
682              values: [[Int!]!]!
683              maybeValues: [[String]]
684            }
685            "#,
686        )
687        .expect("schema should lower");
688
689        let actual = emit_rust(&ir);
690
691        syn::parse_file(&actual).expect("generated Rust should parse");
692        assert!(actual.contains("pub values: Vec<Vec<i32>>,"));
693        assert!(actual.contains(
694            "#[serde(rename = \"maybeValues\")]\n    pub maybe_values: Option<Vec<Option<Vec<Option<String>>>>>,"
695        ));
696    }
697
698    #[test]
699    fn emits_jedit_shaped_hot_text_fixture() {
700        let ir = lower_schema_sdl(include_str!(
701            "../../../test/fixtures/consumer-models/jedit-hot-text-core.graphql"
702        ))
703        .expect("jedit-shaped fixture should lower");
704
705        let actual = emit_rust(&ir);
706
707        syn::parse_file(&actual).expect("generated Rust should parse");
708        assert!(actual.contains("pub struct BufferWorldline {"));
709        assert!(actual.contains("pub enum AnchorKind {"));
710        assert!(actual.contains("pub checkpoints: Vec<Checkpoint>,"));
711        assert!(actual.contains("pub created_at_tick_id: Option<String>,"));
712        assert!(actual.contains("pub create_initial_checkpoint: Option<bool>,"));
713    }
714
715    #[test]
716    fn emits_jedit_operation_bindings() {
717        let sdl =
718            include_str!("../../../test/fixtures/consumer-models/jedit-hot-text-runtime.graphql");
719        let ir = lower_schema_sdl(sdl).expect("jedit runtime fixture should lower");
720        let operations =
721            list_schema_operations_sdl(sdl).expect("jedit runtime operations should resolve");
722
723        let actual = emit_rust_with_operations(&ir, &operations);
724
725        syn::parse_file(&actual).expect("generated Rust should parse");
726        assert!(!actual.contains("pub struct Mutation {"));
727        assert!(actual.contains("pub struct MutationCreateBufferWorldlineRequest {"));
728        assert!(actual.contains("pub input: CreateBufferWorldlineInput,"));
729        assert!(actual.contains(
730            "pub type MutationCreateBufferWorldlineResponse = CreateBufferWorldlineResult;"
731        ));
732        assert!(actual.contains("pub const OPERATION_TYPE: &'static str = \"MUTATION\";"));
733        assert!(actual.contains("pub const FIELD_NAME: &'static str = \"createBufferWorldline\";"));
734        assert!(actual.contains("pub const DIRECTIVES_JSON: &'static str = "));
735        assert!(actual.contains("\\\"wes_footprint\\\""));
736    }
737
738    #[test]
739    fn emits_stack_witness_0001_fixture_operation_bindings() {
740        let sdl = include_str!(
741            "../../../test/fixtures/consumer-models/stack-witness-0001-file-history.graphql"
742        );
743        let ir = lower_schema_sdl(sdl).expect("stack witness fixture should lower");
744        let operations =
745            list_schema_operations_sdl(sdl).expect("stack witness operations should resolve");
746
747        let actual = emit_rust_with_operations(&ir, &operations);
748
749        syn::parse_file(&actual).expect("generated Rust should parse");
750        assert!(actual.contains("pub struct MutationCreateBufferRequest {"));
751        assert!(actual.contains("pub input: CreateBufferInput,"));
752        assert!(actual.contains("pub type MutationCreateBufferResponse = MutationReceipt;"));
753        assert!(actual.contains("pub struct MutationReplaceRangeRequest {"));
754        assert!(actual.contains("pub type MutationReplaceRangeResponse = MutationReceipt;"));
755        assert!(actual.contains("pub struct QueryTextWindowRequest {"));
756        assert!(actual.contains("pub type QueryTextWindowResponse = TextWindowReading;"));
757        assert!(actual.contains("pub data_base64: String,"));
758
759        let create_buffer = rust_operation_impl_block(&actual, "MutationCreateBufferRequest");
760        assert!(create_buffer.contains("pub const FIELD_NAME: &'static str = \"createBuffer\";"));
761        assert!(create_buffer.contains("\\\"artifactId\\\":\\\"fixture-file-history-v0\\\""));
762        assert!(create_buffer.contains("\\\"opId\\\":\\\"0x53570001\\\""));
763        assert!(create_buffer.contains("\\\"helperKind\\\":\\\"EINT\\\""));
764        assert!(create_buffer.contains("\\\"targetCodec\\\":\\\"wesley-binary/v0\\\""));
765        assert!(create_buffer.contains(
766            "\\\"fixtureVarsBytes\\\":\\\"stack-witness-0001/createBuffer;name=demo.txt;artifact=fixture-file-history-v0\\\""
767        ));
768
769        let replace_range = rust_operation_impl_block(&actual, "MutationReplaceRangeRequest");
770        assert!(replace_range.contains("pub const FIELD_NAME: &'static str = \"replaceRange\";"));
771        assert!(replace_range.contains("\\\"artifactId\\\":\\\"fixture-file-history-v0\\\""));
772        assert!(replace_range.contains("\\\"opId\\\":\\\"0x53570002\\\""));
773        assert!(replace_range.contains("\\\"helperKind\\\":\\\"EINT\\\""));
774        assert!(replace_range.contains("\\\"targetCodec\\\":\\\"wesley-binary/v0\\\""));
775        assert!(replace_range.contains(
776            "\\\"fixtureVarsBytes\\\":\\\"stack-witness-0001/replaceRange;bufferId=demo.txt;basis=B0;coord=utf8-bytes;start=0;end=0;text=hello;artifact=fixture-file-history-v0\\\""
777        ));
778
779        let text_window = rust_operation_impl_block(&actual, "QueryTextWindowRequest");
780        assert!(text_window.contains("pub const FIELD_NAME: &'static str = \"textWindow\";"));
781        assert!(text_window.contains("\\\"artifactId\\\":\\\"fixture-file-history-v0\\\""));
782        assert!(text_window.contains("\\\"opId\\\":\\\"0x53571001\\\""));
783        assert!(text_window.contains("\\\"helperKind\\\":\\\"QueryView\\\""));
784        assert!(text_window.contains("\\\"targetCodec\\\":\\\"wesley-binary/v0\\\""));
785        assert!(text_window.contains(
786            "\\\"fixtureVarsBytes\\\":\\\"stack-witness-0001/textWindow;bufferId=demo.txt;basis=B1;coord=utf8-bytes;start=0;length=5;artifact=fixture-file-history-v0\\\""
787        ));
788        assert!(text_window.contains("\\\"payloadCodec\\\":\\\"QueryBytes\\\""));
789        assert!(text_window.contains("\\\"envelope\\\":\\\"ReadingEnvelope\\\""));
790    }
791
792    #[test]
793    fn operation_bindings_include_operation_scope_in_type_names() {
794        let sdl = r#"
795            type Query {
796              status: Status!
797            }
798
799            type Mutation {
800              status(input: StatusInput!): Status!
801            }
802
803            type Status {
804              ok: Boolean!
805            }
806
807            input StatusInput {
808              reason: String
809            }
810        "#;
811        let ir = lower_schema_sdl(sdl).expect("schema should lower");
812        let operations = list_schema_operations_sdl(sdl).expect("operations should resolve");
813
814        let actual = emit_rust_with_operations(&ir, &operations);
815
816        syn::parse_file(&actual).expect("generated Rust should parse");
817        assert!(actual.contains("pub struct QueryStatusRequest {"));
818        assert!(actual.contains("pub type QueryStatusResponse = Status;"));
819        assert!(actual.contains("pub struct MutationStatusRequest {"));
820        assert!(actual.contains("pub type MutationStatusResponse = Status;"));
821    }
822
823    #[test]
824    fn sanitizes_reserved_field_names() {
825        assert_eq!(rust_field_name("type"), "r#type");
826        assert_eq!(rust_field_name("displayName"), "display_name");
827    }
828
829    #[test]
830    fn union_payload_enums_do_not_derive_eq() {
831        let ir = lower_schema_sdl(
832            r#"
833            type User {
834              id: ID!
835            }
836
837            union SearchResult = User
838            "#,
839        )
840        .expect("schema should lower");
841
842        let actual = emit_rust(&ir);
843
844        syn::parse_file(&actual).expect("generated Rust should parse");
845        assert!(actual.contains(
846            "#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]\npub enum SearchResult"
847        ));
848    }
849
850    fn rust_operation_impl_block<'a>(actual: &'a str, request_name: &str) -> &'a str {
851        let marker = format!("impl {request_name} {{");
852        let start = actual
853            .find(&marker)
854            .expect("operation impl block should exist");
855        let tail = &actual[start..];
856        let end = tail
857            .find("\n}\n")
858            .expect("operation impl block should close")
859            + "\n}\n".len();
860        &tail[..end]
861    }
862}