Skip to main content

alef_e2e/codegen/
csharp.rs

1//! C# e2e test generator using xUnit.
2//!
3//! Generates `e2e/csharp/E2eTests.csproj` and `tests/{Category}Tests.cs`
4//! files from JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use crate::config::E2eConfig;
7use crate::escape::{escape_csharp, sanitize_filename};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions as tv;
14use anyhow::Result;
15use heck::ToUpperCamelCase;
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21
22/// C# e2e code generator.
23pub struct CSharpCodegen;
24
25impl E2eCodegen for CSharpCodegen {
26    fn generate(
27        &self,
28        groups: &[FixtureGroup],
29        e2e_config: &E2eConfig,
30        alef_config: &AlefConfig,
31    ) -> Result<Vec<GeneratedFile>> {
32        let lang = self.language_name();
33        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
34
35        let mut files = Vec::new();
36
37        // Resolve call config with overrides.
38        let call = &e2e_config.call;
39        let overrides = call.overrides.get(lang);
40        let function_name = overrides
41            .and_then(|o| o.function.as_ref())
42            .cloned()
43            .unwrap_or_else(|| call.function.to_upper_camel_case());
44        let class_name = overrides
45            .and_then(|o| o.class.as_ref())
46            .cloned()
47            .unwrap_or_else(|| format!("{}Lib", alef_config.crate_config.name.to_upper_camel_case()));
48        // The exception class is always {CrateName}Exception, generated by the C# backend.
49        let exception_class = format!("{}Exception", alef_config.crate_config.name.to_upper_camel_case());
50        let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
51            if call.module.is_empty() {
52                "Kreuzberg".to_string()
53            } else {
54                call.module.to_upper_camel_case()
55            }
56        });
57        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
58        let result_var = &call.result_var;
59        let is_async = call.r#async;
60
61        // Resolve package config.
62        let cs_pkg = e2e_config.resolve_package("csharp");
63        let pkg_name = cs_pkg
64            .as_ref()
65            .and_then(|p| p.name.as_ref())
66            .cloned()
67            .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
68        // The project reference path uses the crate name (with hyphens) for the directory
69        // and the PascalCase name for the .csproj file.
70        let pkg_path = cs_pkg
71            .as_ref()
72            .and_then(|p| p.path.as_ref())
73            .cloned()
74            .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}.csproj"));
75        let pkg_version = cs_pkg
76            .as_ref()
77            .and_then(|p| p.version.as_ref())
78            .cloned()
79            .unwrap_or_else(|| "0.1.0".to_string());
80
81        // Generate the .csproj using a unique name derived from the package name so
82        // it does not conflict with any hand-written project files in the same directory.
83        let csproj_name = format!("{pkg_name}.E2eTests.csproj");
84        files.push(GeneratedFile {
85            path: output_base.join(&csproj_name),
86            content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
87            generated_header: false,
88        });
89
90        // Generate test files per category.
91        let tests_base = output_base.join("tests");
92        let field_resolver = FieldResolver::new(
93            &e2e_config.fields,
94            &e2e_config.fields_optional,
95            &e2e_config.result_fields,
96            &e2e_config.fields_array,
97        );
98
99        // Resolve enum_fields from C# override config.
100        static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
101        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
102
103        for group in groups {
104            let active: Vec<&Fixture> = group
105                .fixtures
106                .iter()
107                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
108                .collect();
109
110            if active.is_empty() {
111                continue;
112            }
113
114            let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
115            let filename = format!("{test_class}.cs");
116            let content = render_test_file(
117                &group.category,
118                &active,
119                &namespace,
120                &class_name,
121                &function_name,
122                &exception_class,
123                result_var,
124                &test_class,
125                &e2e_config.call.args,
126                &field_resolver,
127                result_is_simple,
128                is_async,
129                e2e_config,
130                enum_fields,
131            );
132            files.push(GeneratedFile {
133                path: tests_base.join(filename),
134                content,
135                generated_header: true,
136            });
137        }
138
139        Ok(files)
140    }
141
142    fn language_name(&self) -> &'static str {
143        "csharp"
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Rendering
149// ---------------------------------------------------------------------------
150
151fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
152    let pkg_ref = match dep_mode {
153        crate::config::DependencyMode::Registry => {
154            format!("    <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
155        }
156        crate::config::DependencyMode::Local => {
157            format!("    <ProjectReference Include=\"{pkg_path}\" />")
158        }
159    };
160    format!(
161        r#"<Project Sdk="Microsoft.NET.Sdk">
162  <PropertyGroup>
163    <TargetFramework>net10.0</TargetFramework>
164    <Nullable>enable</Nullable>
165    <ImplicitUsings>enable</ImplicitUsings>
166    <IsPackable>false</IsPackable>
167    <IsTestProject>true</IsTestProject>
168  </PropertyGroup>
169
170  <ItemGroup>
171    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="{ms_test_sdk}" />
172    <PackageReference Include="xunit" Version="{xunit}" />
173    <PackageReference Include="xunit.runner.visualstudio" Version="{xunit_runner}" />
174  </ItemGroup>
175
176  <ItemGroup>
177{pkg_ref}
178  </ItemGroup>
179</Project>
180"#,
181        ms_test_sdk = tv::nuget::MICROSOFT_NET_TEST_SDK,
182        xunit = tv::nuget::XUNIT,
183        xunit_runner = tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
184    )
185}
186
187#[allow(clippy::too_many_arguments)]
188fn render_test_file(
189    category: &str,
190    fixtures: &[&Fixture],
191    namespace: &str,
192    class_name: &str,
193    function_name: &str,
194    exception_class: &str,
195    result_var: &str,
196    test_class: &str,
197    args: &[crate::config::ArgMapping],
198    field_resolver: &FieldResolver,
199    result_is_simple: bool,
200    is_async: bool,
201    e2e_config: &E2eConfig,
202    enum_fields: &HashMap<String, String>,
203) -> String {
204    let mut out = String::new();
205    out.push_str(&hash::header(CommentStyle::DoubleSlash));
206    // Always import System.Text.Json for the shared JsonOptions field.
207    let _ = writeln!(out, "using System.Text.Json;");
208    let _ = writeln!(out, "using System.Text.Json.Serialization;");
209    let _ = writeln!(out, "using System.Threading.Tasks;");
210    let _ = writeln!(out, "using Xunit;");
211    let _ = writeln!(out, "using {namespace};");
212    let _ = writeln!(out);
213    let _ = writeln!(out, "namespace Kreuzberg.E2e;");
214    let _ = writeln!(out);
215    let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
216    let _ = writeln!(out, "public class {test_class}");
217    let _ = writeln!(out, "{{");
218    // Shared options used when deserializing config JSON in test setup.
219    // Mirrors the options used by the library to ensure enum values round-trip correctly.
220    let _ = writeln!(
221        out,
222        "    private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
223    );
224    let _ = writeln!(out);
225
226    for (i, fixture) in fixtures.iter().enumerate() {
227        render_test_method(
228            &mut out,
229            fixture,
230            class_name,
231            function_name,
232            exception_class,
233            result_var,
234            args,
235            field_resolver,
236            result_is_simple,
237            is_async,
238            e2e_config,
239            enum_fields,
240        );
241        if i + 1 < fixtures.len() {
242            let _ = writeln!(out);
243        }
244    }
245
246    let _ = writeln!(out, "}}");
247    out
248}
249
250#[allow(clippy::too_many_arguments)]
251fn render_test_method(
252    out: &mut String,
253    fixture: &Fixture,
254    class_name: &str,
255    _function_name: &str,
256    exception_class: &str,
257    _result_var: &str,
258    _args: &[crate::config::ArgMapping],
259    field_resolver: &FieldResolver,
260    result_is_simple: bool,
261    _is_async: bool,
262    e2e_config: &E2eConfig,
263    enum_fields: &HashMap<String, String>,
264) {
265    let method_name = fixture.id.to_upper_camel_case();
266    let description = &fixture.description;
267    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
268
269    // Resolve call config per-fixture so named calls (e.g. "parse") use the
270    // correct function name, result variable, and async flag.
271    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
272    let lang = "csharp";
273    let cs_overrides = call_config.overrides.get(lang);
274    let effective_function_name = cs_overrides
275        .and_then(|o| o.function.as_ref())
276        .cloned()
277        .unwrap_or_else(|| call_config.function.to_upper_camel_case());
278    let effective_result_var = &call_config.result_var;
279    let effective_is_async = call_config.r#async;
280    let function_name = effective_function_name.as_str();
281    let result_var = effective_result_var.as_str();
282    let is_async = effective_is_async;
283    let args = call_config.args.as_slice();
284
285    let (mut setup_lines, args_str) =
286        build_args_and_setup(&fixture.input, args, class_name, e2e_config, enum_fields, &fixture.id);
287
288    // Build visitor if present and add to setup
289    let mut visitor_arg = String::new();
290    if let Some(visitor_spec) = &fixture.visitor {
291        visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_spec);
292    }
293
294    let final_args = if visitor_arg.is_empty() {
295        args_str
296    } else {
297        format!("{args_str}, {visitor_arg}")
298    };
299
300    let return_type = if is_async { "async Task" } else { "void" };
301    let await_kw = if is_async { "await " } else { "" };
302
303    let _ = writeln!(out, "    [Fact]");
304    let _ = writeln!(out, "    public {return_type} Test_{method_name}()");
305    let _ = writeln!(out, "    {{");
306    let _ = writeln!(out, "        // {description}");
307
308    for line in &setup_lines {
309        let _ = writeln!(out, "        {line}");
310    }
311
312    if expects_error {
313        if is_async {
314            let _ = writeln!(
315                out,
316                "        await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
317            );
318        } else {
319            let _ = writeln!(
320                out,
321                "        Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
322            );
323        }
324        let _ = writeln!(out, "    }}");
325        return;
326    }
327
328    let _ = writeln!(
329        out,
330        "        var {result_var} = {await_kw}{class_name}.{function_name}({final_args});"
331    );
332
333    for assertion in &fixture.assertions {
334        render_assertion(
335            out,
336            assertion,
337            result_var,
338            class_name,
339            exception_class,
340            field_resolver,
341            result_is_simple,
342        );
343    }
344
345    let _ = writeln!(out, "    }}");
346}
347
348/// Build setup lines (e.g. handle creation) and the argument list for the function call.
349///
350/// Returns `(setup_lines, args_string)`.
351fn build_args_and_setup(
352    input: &serde_json::Value,
353    args: &[crate::config::ArgMapping],
354    class_name: &str,
355    e2e_config: &E2eConfig,
356    enum_fields: &HashMap<String, String>,
357    fixture_id: &str,
358) -> (Vec<String>, String) {
359    if args.is_empty() {
360        return (Vec::new(), json_to_csharp(input));
361    }
362
363    let overrides = e2e_config.call.overrides.get("csharp");
364    let options_type = overrides.and_then(|o| o.options_type.as_deref());
365
366    let mut setup_lines: Vec<String> = Vec::new();
367    let mut parts: Vec<String> = Vec::new();
368
369    for arg in args {
370        if arg.arg_type == "mock_url" {
371            setup_lines.push(format!(
372                "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
373                arg.name,
374            ));
375            parts.push(arg.name.clone());
376            continue;
377        }
378
379        if arg.arg_type == "handle" {
380            // Generate a CreateEngine (or equivalent) call and pass the variable.
381            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
382            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
383            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
384            if config_value.is_null()
385                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
386            {
387                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
388            } else {
389                // Sort discriminator fields ("type") to appear first in nested objects so
390                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
391                // reading other properties (a requirement as of .NET 8).
392                let sorted = sort_discriminator_first(config_value.clone());
393                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
394                let name = &arg.name;
395                setup_lines.push(format!(
396                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
397                    escape_csharp(&json_str),
398                ));
399                setup_lines.push(format!(
400                    "var {} = {class_name}.{constructor_name}({name}Config);",
401                    arg.name,
402                    name = name,
403                ));
404            }
405            parts.push(arg.name.clone());
406            continue;
407        }
408
409        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
410        let val = input.get(field);
411        match val {
412            None | Some(serde_json::Value::Null) if arg.optional => {
413                // Optional arg with no fixture value: pass null explicitly since
414                // C# nullable parameters still require an argument at the call site.
415                parts.push("null".to_string());
416                continue;
417            }
418            None | Some(serde_json::Value::Null) => {
419                // Required arg with no fixture value: pass a language-appropriate default.
420                let default_val = match arg.arg_type.as_str() {
421                    "string" => "\"\"".to_string(),
422                    "int" | "integer" => "0".to_string(),
423                    "float" | "number" => "0.0d".to_string(),
424                    "bool" | "boolean" => "false".to_string(),
425                    _ => "null".to_string(),
426                };
427                parts.push(default_val);
428            }
429            Some(v) => {
430                // For json_object args with options_type, construct a typed C# object.
431                if let (Some(opts_type), "json_object") = (options_type, arg.arg_type.as_str()) {
432                    if let Some(obj) = v.as_object() {
433                        let props: Vec<String> = obj
434                            .iter()
435                            .map(|(k, vv)| {
436                                let pascal_key = k.to_upper_camel_case();
437                                // Check if this field maps to an enum type.
438                                let cs_val = if let Some(enum_type) = enum_fields.get(k) {
439                                    // Map string value to enum constant (PascalCase).
440                                    if let Some(s) = vv.as_str() {
441                                        let pascal_val = s.to_upper_camel_case();
442                                        format!("{enum_type}.{pascal_val}")
443                                    } else {
444                                        json_to_csharp(vv)
445                                    }
446                                } else {
447                                    json_to_csharp(vv)
448                                };
449                                format!("{pascal_key} = {cs_val}")
450                            })
451                            .collect();
452                        parts.push(format!("new {opts_type} {{ {} }}", props.join(", ")));
453                        continue;
454                    }
455                }
456                parts.push(json_to_csharp(v));
457            }
458        }
459    }
460
461    (setup_lines, parts.join(", "))
462}
463
464fn render_assertion(
465    out: &mut String,
466    assertion: &Assertion,
467    result_var: &str,
468    class_name: &str,
469    exception_class: &str,
470    field_resolver: &FieldResolver,
471    result_is_simple: bool,
472) {
473    // Skip assertions on fields that don't exist on the result type.
474    if let Some(f) = &assertion.field {
475        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
476            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
477            return;
478        }
479    }
480
481    let field_expr = if result_is_simple {
482        result_var.to_string()
483    } else {
484        match &assertion.field {
485            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", result_var),
486            _ => result_var.to_string(),
487        }
488    };
489
490    // Determine whether the field resolves to an optional (nullable) type in C#.
491    let field_is_optional = assertion
492        .field
493        .as_deref()
494        .map(|f| field_resolver.is_optional(field_resolver.resolve(f)))
495        .unwrap_or(false);
496
497    match assertion.assertion_type.as_str() {
498        "equals" => {
499            if let Some(expected) = &assertion.value {
500                let cs_val = json_to_csharp(expected);
501                // Only call .Trim() on string fields, not numeric or boolean ones.
502                if expected.is_string() {
503                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr}.Trim());");
504                } else if expected.is_number() && field_is_optional {
505                    // Nullable numeric fields require an explicit cast of the expected
506                    // literal so that C# can resolve the overload (e.g. ulong?).
507                    let _ = writeln!(out, "        Assert.Equal((object?){cs_val}, (object?){field_expr});");
508                } else {
509                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr});");
510                }
511            }
512        }
513        "contains" => {
514            if let Some(expected) = &assertion.value {
515                // Lowercase both expected and actual so that enum fields (where .ToString()
516                // returns the PascalCase C# member name like "Anchor") correctly match
517                // fixture snake_case values like "anchor".  String fields are unaffected
518                // because lowercasing both sides preserves substring matches.
519                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
520                let cs_val = lower_expected
521                    .as_deref()
522                    .map(|s| format!("\"{}\"", escape_csharp(s)))
523                    .unwrap_or_else(|| json_to_csharp(expected));
524                let _ = writeln!(
525                    out,
526                    "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
527                );
528            }
529        }
530        "contains_all" => {
531            if let Some(values) = &assertion.values {
532                for val in values {
533                    let lower_val = val.as_str().map(|s| s.to_lowercase());
534                    let cs_val = lower_val
535                        .as_deref()
536                        .map(|s| format!("\"{}\"", escape_csharp(s)))
537                        .unwrap_or_else(|| json_to_csharp(val));
538                    let _ = writeln!(
539                        out,
540                        "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
541                    );
542                }
543            }
544        }
545        "not_contains" => {
546            if let Some(expected) = &assertion.value {
547                let cs_val = json_to_csharp(expected);
548                let _ = writeln!(out, "        Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
549            }
550        }
551        "not_empty" => {
552            let _ = writeln!(
553                out,
554                "        Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
555            );
556        }
557        "is_empty" => {
558            let _ = writeln!(
559                out,
560                "        Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
561            );
562        }
563        "contains_any" => {
564            if let Some(values) = &assertion.values {
565                let checks: Vec<String> = values
566                    .iter()
567                    .map(|v| {
568                        let cs_val = json_to_csharp(v);
569                        format!("{field_expr}.ToString().Contains({cs_val})")
570                    })
571                    .collect();
572                let joined = checks.join(" || ");
573                let _ = writeln!(
574                    out,
575                    "        Assert.True({joined}, \"expected to contain at least one of the specified values\");"
576                );
577            }
578        }
579        "greater_than" => {
580            if let Some(val) = &assertion.value {
581                let cs_val = json_to_csharp(val);
582                let _ = writeln!(
583                    out,
584                    "        Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
585                );
586            }
587        }
588        "less_than" => {
589            if let Some(val) = &assertion.value {
590                let cs_val = json_to_csharp(val);
591                let _ = writeln!(
592                    out,
593                    "        Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
594                );
595            }
596        }
597        "greater_than_or_equal" => {
598            if let Some(val) = &assertion.value {
599                let cs_val = json_to_csharp(val);
600                let _ = writeln!(
601                    out,
602                    "        Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
603                );
604            }
605        }
606        "less_than_or_equal" => {
607            if let Some(val) = &assertion.value {
608                let cs_val = json_to_csharp(val);
609                let _ = writeln!(
610                    out,
611                    "        Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
612                );
613            }
614        }
615        "starts_with" => {
616            if let Some(expected) = &assertion.value {
617                let cs_val = json_to_csharp(expected);
618                let _ = writeln!(out, "        Assert.StartsWith({cs_val}, {field_expr});");
619            }
620        }
621        "ends_with" => {
622            if let Some(expected) = &assertion.value {
623                let cs_val = json_to_csharp(expected);
624                let _ = writeln!(out, "        Assert.EndsWith({cs_val}, {field_expr});");
625            }
626        }
627        "min_length" => {
628            if let Some(val) = &assertion.value {
629                if let Some(n) = val.as_u64() {
630                    let _ = writeln!(
631                        out,
632                        "        Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
633                    );
634                }
635            }
636        }
637        "max_length" => {
638            if let Some(val) = &assertion.value {
639                if let Some(n) = val.as_u64() {
640                    let _ = writeln!(
641                        out,
642                        "        Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
643                    );
644                }
645            }
646        }
647        "count_min" => {
648            if let Some(val) = &assertion.value {
649                if let Some(n) = val.as_u64() {
650                    let _ = writeln!(
651                        out,
652                        "        Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
653                    );
654                }
655            }
656        }
657        "count_equals" => {
658            if let Some(val) = &assertion.value {
659                if let Some(n) = val.as_u64() {
660                    let _ = writeln!(out, "        Assert.Equal({n}, {field_expr}.Count);");
661                }
662            }
663        }
664        "is_true" => {
665            let _ = writeln!(out, "        Assert.True({field_expr});");
666        }
667        "is_false" => {
668            let _ = writeln!(out, "        Assert.False({field_expr});");
669        }
670        "not_error" => {
671            // Already handled by the call succeeding without exception.
672        }
673        "error" => {
674            // Handled at the test method level.
675        }
676        "method_result" => {
677            if let Some(method_name) = &assertion.method {
678                let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
679                let check = assertion.check.as_deref().unwrap_or("is_true");
680                match check {
681                    "equals" => {
682                        if let Some(val) = &assertion.value {
683                            if val.as_bool() == Some(true) {
684                                let _ = writeln!(out, "        Assert.True({call_expr});");
685                            } else if val.as_bool() == Some(false) {
686                                let _ = writeln!(out, "        Assert.False({call_expr});");
687                            } else {
688                                let cs_val = json_to_csharp(val);
689                                let _ = writeln!(out, "        Assert.Equal({cs_val}, {call_expr});");
690                            }
691                        }
692                    }
693                    "is_true" => {
694                        let _ = writeln!(out, "        Assert.True({call_expr});");
695                    }
696                    "is_false" => {
697                        let _ = writeln!(out, "        Assert.False({call_expr});");
698                    }
699                    "greater_than_or_equal" => {
700                        if let Some(val) = &assertion.value {
701                            let n = val.as_u64().unwrap_or(0);
702                            let _ = writeln!(out, "        Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
703                        }
704                    }
705                    "count_min" => {
706                        if let Some(val) = &assertion.value {
707                            let n = val.as_u64().unwrap_or(0);
708                            let _ = writeln!(
709                                out,
710                                "        Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
711                            );
712                        }
713                    }
714                    "is_error" => {
715                        let _ = writeln!(
716                            out,
717                            "        Assert.Throws<{exception_class}>(() => {{ {call_expr}; }});"
718                        );
719                    }
720                    "contains" => {
721                        if let Some(val) = &assertion.value {
722                            let cs_val = json_to_csharp(val);
723                            let _ = writeln!(out, "        Assert.Contains({cs_val}, {call_expr});");
724                        }
725                    }
726                    other_check => {
727                        panic!("C# e2e generator: unsupported method_result check type: {other_check}");
728                    }
729                }
730            } else {
731                panic!("C# e2e generator: method_result assertion missing 'method' field");
732            }
733        }
734        "matches_regex" => {
735            if let Some(expected) = &assertion.value {
736                let cs_val = json_to_csharp(expected);
737                let _ = writeln!(out, "        Assert.Matches({cs_val}, {field_expr});");
738            }
739        }
740        other => {
741            panic!("C# e2e generator: unsupported assertion type: {other}");
742        }
743    }
744}
745
746/// Recursively sort JSON objects so that any key named `"type"` appears first.
747///
748/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
749/// the first property when deserializing polymorphic types. Fixture config values
750/// serialised via serde_json preserve insertion/alphabetical order, which may put
751/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
752fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
753    match value {
754        serde_json::Value::Object(map) => {
755            let mut sorted = serde_json::Map::with_capacity(map.len());
756            // Insert "type" first if present.
757            if let Some(type_val) = map.get("type") {
758                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
759            }
760            for (k, v) in map {
761                if k != "type" {
762                    sorted.insert(k, sort_discriminator_first(v));
763                }
764            }
765            serde_json::Value::Object(sorted)
766        }
767        serde_json::Value::Array(arr) => {
768            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
769        }
770        other => other,
771    }
772}
773
774/// Convert a `serde_json::Value` to a C# literal string.
775fn json_to_csharp(value: &serde_json::Value) -> String {
776    match value {
777        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
778        serde_json::Value::Bool(true) => "true".to_string(),
779        serde_json::Value::Bool(false) => "false".to_string(),
780        serde_json::Value::Number(n) => {
781            if n.is_f64() {
782                format!("{}d", n)
783            } else {
784                n.to_string()
785            }
786        }
787        serde_json::Value::Null => "null".to_string(),
788        serde_json::Value::Array(arr) => {
789            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
790            format!("new[] {{ {} }}", items.join(", "))
791        }
792        serde_json::Value::Object(_) => {
793            let json_str = serde_json::to_string(value).unwrap_or_default();
794            format!("\"{}\"", escape_csharp(&json_str))
795        }
796    }
797}
798
799// ---------------------------------------------------------------------------
800// Visitor generation
801// ---------------------------------------------------------------------------
802
803/// Build a C# visitor class and add setup lines. Returns the visitor variable name.
804fn build_csharp_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
805    setup_lines.push("var _testVisitor = new TestVisitor();".to_string());
806    setup_lines.push("class TestVisitor : IVisitor".to_string());
807    setup_lines.push("{".to_string());
808    for (method_name, action) in &visitor_spec.callbacks {
809        emit_csharp_visitor_method(setup_lines, method_name, action);
810    }
811    setup_lines.push("}".to_string());
812    "_testVisitor".to_string()
813}
814
815/// Emit a C# visitor method for a callback action.
816fn emit_csharp_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
817    let camel_method = method_to_camel(method_name);
818    let params = match method_name {
819        "visit_link" => "VisitContext ctx, string href, string text, string title",
820        "visit_image" => "VisitContext ctx, string src, string alt, string title",
821        "visit_heading" => "VisitContext ctx, int level, string text, string id",
822        "visit_code_block" => "VisitContext ctx, string lang, string code",
823        "visit_code_inline"
824        | "visit_strong"
825        | "visit_emphasis"
826        | "visit_strikethrough"
827        | "visit_underline"
828        | "visit_subscript"
829        | "visit_superscript"
830        | "visit_mark"
831        | "visit_button"
832        | "visit_summary"
833        | "visit_figcaption"
834        | "visit_definition_term"
835        | "visit_definition_description" => "VisitContext ctx, string text",
836        "visit_text" => "VisitContext ctx, string text",
837        "visit_list_item" => "VisitContext ctx, bool ordered, string marker, string text",
838        "visit_blockquote" => "VisitContext ctx, string content, int depth",
839        "visit_table_row" => "VisitContext ctx, IReadOnlyList<string> cells, bool isHeader",
840        "visit_custom_element" => "VisitContext ctx, string tagName, string html",
841        "visit_form" => "VisitContext ctx, string actionUrl, string method",
842        "visit_input" => "VisitContext ctx, string inputType, string name, string value",
843        "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, string src",
844        "visit_details" => "VisitContext ctx, bool isOpen",
845        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
846            "VisitContext ctx, string output"
847        }
848        "visit_list_start" => "VisitContext ctx, bool ordered",
849        "visit_list_end" => "VisitContext ctx, bool ordered, string output",
850        _ => "VisitContext ctx",
851    };
852
853    setup_lines.push(format!("    public VisitResult {camel_method}({params})"));
854    setup_lines.push("    {".to_string());
855    match action {
856        CallbackAction::Skip => {
857            setup_lines.push("        return VisitResult.Skip();".to_string());
858        }
859        CallbackAction::Continue => {
860            setup_lines.push("        return VisitResult.Continue();".to_string());
861        }
862        CallbackAction::PreserveHtml => {
863            setup_lines.push("        return VisitResult.PreserveHtml();".to_string());
864        }
865        CallbackAction::Custom { output } => {
866            let escaped = escape_csharp(output);
867            setup_lines.push(format!("        return VisitResult.Custom(\"{escaped}\");"));
868        }
869        CallbackAction::CustomTemplate { template } => {
870            let escaped = escape_csharp(template);
871            setup_lines.push(format!("        return VisitResult.Custom($\"{escaped}\");"));
872        }
873    }
874    setup_lines.push("    }".to_string());
875}
876
877/// Convert snake_case method names to C# PascalCase.
878fn method_to_camel(snake: &str) -> String {
879    use heck::ToUpperCamelCase;
880    snake.to_upper_camel_case()
881}
882
883/// Build a C# call expression for a `method_result` assertion on a tree-sitter Tree.
884///
885/// Maps well-known method names to the appropriate C# static helper calls on the
886/// generated lib class, falling back to `result_var.PascalCase()` for unknowns.
887fn build_csharp_method_call(
888    result_var: &str,
889    method_name: &str,
890    args: Option<&serde_json::Value>,
891    class_name: &str,
892) -> String {
893    match method_name {
894        "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
895        "root_node_type" => format!("{result_var}.RootNode.Kind"),
896        "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
897        "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
898        "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
899        "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
900        "contains_node_type" => {
901            let node_type = args
902                .and_then(|a| a.get("node_type"))
903                .and_then(|v| v.as_str())
904                .unwrap_or("");
905            format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
906        }
907        "find_nodes_by_type" => {
908            let node_type = args
909                .and_then(|a| a.get("node_type"))
910                .and_then(|v| v.as_str())
911                .unwrap_or("");
912            format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
913        }
914        "run_query" => {
915            let query_source = args
916                .and_then(|a| a.get("query_source"))
917                .and_then(|v| v.as_str())
918                .unwrap_or("");
919            let language = args
920                .and_then(|a| a.get("language"))
921                .and_then(|v| v.as_str())
922                .unwrap_or("");
923            format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
924        }
925        _ => {
926            use heck::ToUpperCamelCase;
927            let pascal = method_name.to_upper_camel_case();
928            format!("{result_var}.{pascal}()")
929        }
930    }
931}