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, HttpFixture};
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;");
208    let _ = writeln!(out, "using System.Collections.Generic;");
209    let _ = writeln!(out, "using System.Linq;");
210    let _ = writeln!(out, "using System.Net.Http;");
211    let _ = writeln!(out, "using System.Text;");
212    let _ = writeln!(out, "using System.Text.Json;");
213    let _ = writeln!(out, "using System.Text.Json.Serialization;");
214    let _ = writeln!(out, "using System.Threading.Tasks;");
215    let _ = writeln!(out, "using Xunit;");
216    let _ = writeln!(out, "using {namespace};");
217    let _ = writeln!(out);
218    let _ = writeln!(out, "namespace Kreuzberg.E2e;");
219    let _ = writeln!(out);
220    let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
221    let _ = writeln!(out, "public class {test_class}");
222    let _ = writeln!(out, "{{");
223    // Shared options used when deserializing config JSON in test setup.
224    // Mirrors the options used by the library to ensure enum values round-trip correctly.
225    let _ = writeln!(
226        out,
227        "    private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
228    );
229    let _ = writeln!(out);
230
231    for (i, fixture) in fixtures.iter().enumerate() {
232        render_test_method(
233            &mut out,
234            fixture,
235            class_name,
236            function_name,
237            exception_class,
238            result_var,
239            args,
240            field_resolver,
241            result_is_simple,
242            is_async,
243            e2e_config,
244            enum_fields,
245        );
246        if i + 1 < fixtures.len() {
247            let _ = writeln!(out);
248        }
249    }
250
251    let _ = writeln!(out, "}}");
252    out
253}
254
255/// Render an HTTP server test method using System.Net.Http.HttpClient against MOCK_SERVER_URL.
256fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
257    let method_name = fixture.id.to_upper_camel_case();
258    let description = &fixture.description;
259    let request = &http.request;
260    let expected = &http.expected_response;
261    // C#'s System.Net.Http.HttpMethod static properties are PascalCase: Get, Post, Put, Delete, ...
262    let method = {
263        let lower = request.method.to_ascii_lowercase();
264        let mut chars = lower.chars();
265        match chars.next() {
266            Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
267            None => String::new(),
268        }
269    };
270    let fixture_id = &fixture.id;
271    let expected_status = expected.status_code;
272
273    let _ = writeln!(out, "    [Fact]");
274    let _ = writeln!(out, "    public async Task Test_{method_name}()");
275    let _ = writeln!(out, "    {{");
276    let _ = writeln!(out, "        // {description}");
277    let _ = writeln!(
278        out,
279        "        var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";"
280    );
281    // Disable auto-follow so redirect-status fixtures (3xx) can assert the
282    // server's status code rather than the followed-target's status.
283    let _ = writeln!(
284        out,
285        "        using var handler = new System.Net.Http.HttpClientHandler {{ AllowAutoRedirect = false }};"
286    );
287    let _ = writeln!(
288        out,
289        "        using var client = new System.Net.Http.HttpClient(handler);"
290    );
291    let _ = writeln!(
292        out,
293        "        var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}/fixtures/{fixture_id}\");"
294    );
295
296    // Set Content-Type header if there's a body.
297    let content_type = request.content_type.as_deref().unwrap_or("application/json");
298    if request.body.is_some() {
299        let json_str = serde_json::to_string(&request.body).unwrap_or_default();
300        let escaped = escape_csharp(&json_str);
301        let _ = writeln!(
302            out,
303            "        request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");"
304        );
305    }
306
307    // Add headers (skip restricted headers).
308    const CSHARP_RESTRICTED_HEADERS: &[&str] = &[
309        "content-length",
310        "host",
311        "connection",
312        "expect",
313        "transfer-encoding",
314        "upgrade",
315        // Content-Type is owned by request.Content.Headers and is set when
316        // StringContent is constructed; adding it to request.Headers throws.
317        "content-type",
318        // Other entity headers also belong to request.Content.Headers.
319        "content-encoding",
320        "content-language",
321        "content-location",
322        "content-md5",
323        "content-range",
324        "content-disposition",
325    ];
326
327    for (name, value) in &request.headers {
328        if CSHARP_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
329            continue;
330        }
331        let escaped_name = escape_csharp(name);
332        let escaped_value = escape_csharp(value);
333        let _ = writeln!(
334            out,
335            "        request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");"
336        );
337    }
338
339    // Add cookies as Cookie header.
340    if !request.cookies.is_empty() {
341        let cookie_str: Vec<String> = request.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
342        let cookie_header = escape_csharp(&cookie_str.join("; "));
343        let _ = writeln!(out, "        request.Headers.Add(\"Cookie\", \"{cookie_header}\");");
344    }
345
346    let _ = writeln!(out, "        var response = await client.SendAsync(request);");
347    let _ = writeln!(
348        out,
349        "        Assert.Equal({expected_status}, (int)response.StatusCode);"
350    );
351
352    // Assert body if expected.
353    if let Some(expected_body) = &expected.body {
354        match expected_body {
355            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
356                let json_str = serde_json::to_string(expected_body).unwrap_or_default();
357                let escaped = escape_csharp(&json_str);
358                let _ = writeln!(
359                    out,
360                    "        var bodyText = await response.Content.ReadAsStringAsync();"
361                );
362                let _ = writeln!(out, "        var body = JsonDocument.Parse(bodyText).RootElement;");
363                let _ = writeln!(
364                    out,
365                    "        var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;"
366                );
367                let _ = writeln!(
368                    out,
369                    "        Assert.Equal(expectedBody.GetRawText(), body.GetRawText());"
370                );
371            }
372            serde_json::Value::String(s) => {
373                let escaped = escape_csharp(s);
374                let _ = writeln!(
375                    out,
376                    "        var bodyText = await response.Content.ReadAsStringAsync();"
377                );
378                let _ = writeln!(out, "        Assert.Equal(\"{escaped}\", bodyText.Trim());");
379            }
380            other => {
381                let escaped = escape_csharp(&other.to_string());
382                let _ = writeln!(
383                    out,
384                    "        var bodyText = await response.Content.ReadAsStringAsync();"
385                );
386                let _ = writeln!(out, "        Assert.Equal(\"{escaped}\", bodyText.Trim());");
387            }
388        }
389    }
390
391    // Assert response headers if specified (skip special tokens). Use unique
392    // variable names per assertion since `out var` introduces a new local in
393    // method scope and C# disallows redeclaration.
394    //
395    // HttpResponseMessage splits headers between `.Headers` (general HTTP
396    // headers like Cache-Control) and `.Content.Headers` (entity headers like
397    // Content-Type, Content-Length, Content-Encoding). Pick the right one or
398    // .NET throws "Misused header name".
399    let mut header_idx = 0usize;
400    for (name, value) in &expected.headers {
401        if value == "<<absent>>" || value == "<<present>>" || value == "<<uuid>>" {
402            continue;
403        }
404        // Skip content-encoding since the mock server doesn't compress.
405        if name.to_lowercase() == "content-encoding" {
406            continue;
407        }
408        let lower = name.to_ascii_lowercase();
409        let is_content_header = matches!(
410            lower.as_str(),
411            "content-type"
412                | "content-length"
413                | "content-encoding"
414                | "content-language"
415                | "content-location"
416                | "content-md5"
417                | "content-range"
418                | "content-disposition"
419                | "expires"
420                | "last-modified"
421                | "allow"
422        );
423        let target = if is_content_header {
424            "response.Content.Headers"
425        } else {
426            "response.Headers"
427        };
428        let escaped_name = escape_csharp(name);
429        let escaped_value = escape_csharp(value);
430        let var_name = format!("hdr{header_idx}");
431        header_idx += 1;
432        let _ = writeln!(
433            out,
434            "        Assert.True({target}.TryGetValues(\"{escaped_name}\", out var {var_name}) && {var_name}.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");"
435        );
436    }
437
438    let _ = writeln!(out, "    }}");
439}
440
441#[allow(clippy::too_many_arguments)]
442fn render_test_method(
443    out: &mut String,
444    fixture: &Fixture,
445    class_name: &str,
446    _function_name: &str,
447    exception_class: &str,
448    _result_var: &str,
449    _args: &[crate::config::ArgMapping],
450    field_resolver: &FieldResolver,
451    result_is_simple: bool,
452    _is_async: bool,
453    e2e_config: &E2eConfig,
454    enum_fields: &HashMap<String, String>,
455) {
456    let method_name = fixture.id.to_upper_camel_case();
457    let description = &fixture.description;
458
459    // HTTP fixtures: generate real HTTP client tests using System.Net.Http.
460    if let Some(http) = &fixture.http {
461        render_http_test_method(out, fixture, http);
462        return;
463    }
464
465    // Non-HTTP fixtures with no mock_response: the C# binding wraps a C FFI
466    // layer and does not expose a HandleRequest callable. Emit a documented
467    // skip so the project stays compilable.
468    if fixture.mock_response.is_none() {
469        let _ = writeln!(
470            out,
471            "    [Fact(Skip = \"non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function\")]"
472        );
473        let _ = writeln!(out, "    public void Test_{method_name}()");
474        let _ = writeln!(out, "    {{");
475        let _ = writeln!(out, "        // {description}");
476        let _ = writeln!(out, "    }}");
477        return;
478    }
479
480    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
481
482    // Resolve call config per-fixture so named calls (e.g. "parse") use the
483    // correct function name, result variable, and async flag.
484    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
485    let lang = "csharp";
486    let cs_overrides = call_config.overrides.get(lang);
487    let effective_function_name = cs_overrides
488        .and_then(|o| o.function.as_ref())
489        .cloned()
490        .unwrap_or_else(|| call_config.function.to_upper_camel_case());
491    let effective_result_var = &call_config.result_var;
492    let effective_is_async = call_config.r#async;
493    let function_name = effective_function_name.as_str();
494    let result_var = effective_result_var.as_str();
495    let is_async = effective_is_async;
496    let args = call_config.args.as_slice();
497
498    // Per-call overrides: result shape, void returns, extra trailing args.
499    let per_call_result_is_simple = cs_overrides.is_some_and(|o| o.result_is_simple);
500    let effective_result_is_simple = result_is_simple || per_call_result_is_simple;
501    let returns_void = call_config.returns_void;
502    let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
503    // options_type: prefer per-call override, fall back to top-level csharp override.
504    let top_level_options_type = e2e_config
505        .call
506        .overrides
507        .get("csharp")
508        .and_then(|o| o.options_type.as_deref());
509    let effective_options_type = cs_overrides
510        .and_then(|o| o.options_type.as_deref())
511        .or(top_level_options_type);
512
513    let (mut setup_lines, args_str) = build_args_and_setup(
514        &fixture.input,
515        args,
516        class_name,
517        effective_options_type,
518        enum_fields,
519        &fixture.id,
520    );
521
522    // Build visitor if present and add to setup
523    let mut visitor_arg = String::new();
524    if let Some(visitor_spec) = &fixture.visitor {
525        visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_spec);
526    }
527
528    let args_with_visitor = if visitor_arg.is_empty() {
529        args_str
530    } else {
531        format!("{args_str}, {visitor_arg}")
532    };
533
534    let final_args = if extra_args_slice.is_empty() {
535        args_with_visitor
536    } else if args_with_visitor.is_empty() {
537        extra_args_slice.join(", ")
538    } else {
539        format!("{args_with_visitor}, {}", extra_args_slice.join(", "))
540    };
541
542    let return_type = if is_async { "async Task" } else { "void" };
543    let await_kw = if is_async { "await " } else { "" };
544
545    let _ = writeln!(out, "    [Fact]");
546    let _ = writeln!(out, "    public {return_type} Test_{method_name}()");
547    let _ = writeln!(out, "    {{");
548    let _ = writeln!(out, "        // {description}");
549
550    for line in &setup_lines {
551        let _ = writeln!(out, "        {line}");
552    }
553
554    if expects_error {
555        if is_async {
556            let _ = writeln!(
557                out,
558                "        await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
559            );
560        } else {
561            let _ = writeln!(
562                out,
563                "        Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
564            );
565        }
566        let _ = writeln!(out, "    }}");
567        return;
568    }
569
570    let result_is_vec = cs_overrides.is_some_and(|o| o.result_is_vec);
571
572    if returns_void {
573        let _ = writeln!(out, "        {await_kw}{class_name}.{function_name}({final_args});");
574    } else {
575        let _ = writeln!(
576            out,
577            "        var {result_var} = {await_kw}{class_name}.{function_name}({final_args});"
578        );
579        for assertion in &fixture.assertions {
580            render_assertion(
581                out,
582                assertion,
583                result_var,
584                class_name,
585                exception_class,
586                field_resolver,
587                effective_result_is_simple,
588                result_is_vec,
589            );
590        }
591    }
592
593    let _ = writeln!(out, "    }}");
594}
595
596/// Build setup lines (e.g. handle creation) and the argument list for the function call.
597///
598/// Returns `(setup_lines, args_string)`.
599fn build_args_and_setup(
600    input: &serde_json::Value,
601    args: &[crate::config::ArgMapping],
602    class_name: &str,
603    options_type: Option<&str>,
604    _enum_fields: &HashMap<String, String>,
605    fixture_id: &str,
606) -> (Vec<String>, String) {
607    if args.is_empty() {
608        return (Vec::new(), String::new());
609    }
610
611    let mut setup_lines: Vec<String> = Vec::new();
612    let mut parts: Vec<String> = Vec::new();
613
614    for arg in args {
615        if arg.arg_type == "bytes" {
616            // bytes args must be passed as byte[] in C#.
617            // Treat the fixture value as a UTF-8 string and convert to bytes.
618            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
619            let val = input.get(field);
620            match val {
621                None | Some(serde_json::Value::Null) if arg.optional => {
622                    parts.push("null".to_string());
623                }
624                None | Some(serde_json::Value::Null) => {
625                    parts.push("System.Array.Empty<byte>()".to_string());
626                }
627                Some(v) => {
628                    let cs_str = json_to_csharp(v);
629                    parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
630                }
631            }
632            continue;
633        }
634
635        if arg.arg_type == "mock_url" {
636            setup_lines.push(format!(
637                "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
638                arg.name,
639            ));
640            parts.push(arg.name.clone());
641            continue;
642        }
643
644        if arg.arg_type == "handle" {
645            // Generate a CreateEngine (or equivalent) call and pass the variable.
646            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
647            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
648            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
649            if config_value.is_null()
650                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
651            {
652                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
653            } else {
654                // Sort discriminator fields ("type") to appear first in nested objects so
655                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
656                // reading other properties (a requirement as of .NET 8).
657                let sorted = sort_discriminator_first(config_value.clone());
658                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
659                let name = &arg.name;
660                setup_lines.push(format!(
661                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
662                    escape_csharp(&json_str),
663                ));
664                setup_lines.push(format!(
665                    "var {} = {class_name}.{constructor_name}({name}Config);",
666                    arg.name,
667                    name = name,
668                ));
669            }
670            parts.push(arg.name.clone());
671            continue;
672        }
673
674        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
675        let val = input.get(field);
676        match val {
677            None | Some(serde_json::Value::Null) if arg.optional => {
678                // Optional arg with no fixture value: pass null explicitly since
679                // C# nullable parameters still require an argument at the call site.
680                parts.push("null".to_string());
681                continue;
682            }
683            None | Some(serde_json::Value::Null) => {
684                // Required arg with no fixture value: pass a language-appropriate default.
685                let default_val = match arg.arg_type.as_str() {
686                    "string" => "\"\"".to_string(),
687                    "int" | "integer" => "0".to_string(),
688                    "float" | "number" => "0.0d".to_string(),
689                    "bool" | "boolean" => "false".to_string(),
690                    _ => "null".to_string(),
691                };
692                parts.push(default_val);
693            }
694            Some(v) => {
695                if arg.arg_type == "json_object" {
696                    // Array value: generate a typed List<T> based on element_type.
697                    if let Some(arr) = v.as_array() {
698                        parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
699                        continue;
700                    }
701                    // Object value with known type: deserialize via JsonSerializer so the
702                    // library's own [JsonPropertyName] annotations handle field name mapping.
703                    if let Some(opts_type) = options_type {
704                        if v.is_object() {
705                            let json_str = serde_json::to_string(v).unwrap_or_default();
706                            parts.push(format!(
707                                "JsonSerializer.Deserialize<{opts_type}>(\"{}\", ConfigOptions)!",
708                                escape_csharp(&json_str),
709                            ));
710                            continue;
711                        }
712                    }
713                }
714                parts.push(json_to_csharp(v));
715            }
716        }
717    }
718
719    (setup_lines, parts.join(", "))
720}
721
722/// Convert a JSON array to a typed C# `List<T>` expression.
723///
724/// Mapping from `ArgMapping::element_type`:
725/// - `None` or any string type → `List<string>`
726/// - `"f32"` → `List<float>` with `(float)` casts
727/// - `"(String, String)"` → `List<List<string>>` for key-value pair arrays
728fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
729    match element_type {
730        Some("f32") => {
731            let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
732            format!("new List<float>() {{ {} }}", items.join(", "))
733        }
734        Some("(String, String)") => {
735            let items: Vec<String> = arr
736                .iter()
737                .map(|v| {
738                    let strs: Vec<String> = v
739                        .as_array()
740                        .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
741                    format!("new List<string>() {{ {} }}", strs.join(", "))
742                })
743                .collect();
744            format!("new List<List<string>>() {{ {} }}", items.join(", "))
745        }
746        _ => {
747            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
748            format!("new List<string>() {{ {} }}", items.join(", "))
749        }
750    }
751}
752
753#[allow(clippy::too_many_arguments)]
754fn render_assertion(
755    out: &mut String,
756    assertion: &Assertion,
757    result_var: &str,
758    class_name: &str,
759    exception_class: &str,
760    field_resolver: &FieldResolver,
761    result_is_simple: bool,
762    result_is_vec: bool,
763) {
764    // Handle synthetic / derived fields before the is_valid_for_result check
765    // so they are never treated as struct property accesses on the result.
766    if let Some(f) = &assertion.field {
767        match f.as_str() {
768            "chunks_have_content" => {
769                let pred = format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
770                match assertion.assertion_type.as_str() {
771                    "is_true" => {
772                        let _ = writeln!(out, "        Assert.True({pred});");
773                    }
774                    "is_false" => {
775                        let _ = writeln!(out, "        Assert.False({pred});");
776                    }
777                    _ => {
778                        let _ = writeln!(
779                            out,
780                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
781                        );
782                    }
783                }
784                return;
785            }
786            "chunks_have_embeddings" => {
787                let pred =
788                    format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
789                match assertion.assertion_type.as_str() {
790                    "is_true" => {
791                        let _ = writeln!(out, "        Assert.True({pred});");
792                    }
793                    "is_false" => {
794                        let _ = writeln!(out, "        Assert.False({pred});");
795                    }
796                    _ => {
797                        let _ = writeln!(
798                            out,
799                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
800                        );
801                    }
802                }
803                return;
804            }
805            // ---- EmbedResponse virtual fields ----
806            // embed_texts returns List<List<float>> in C# — no wrapper object.
807            // result_var is the embedding matrix; use it directly.
808            "embeddings" => {
809                match assertion.assertion_type.as_str() {
810                    "count_equals" => {
811                        if let Some(val) = &assertion.value {
812                            let cs_val = json_to_csharp(val);
813                            let _ = writeln!(out, "        Assert.True({result_var}.Count == {cs_val});");
814                        }
815                    }
816                    "count_min" => {
817                        if let Some(val) = &assertion.value {
818                            let cs_val = json_to_csharp(val);
819                            let _ = writeln!(out, "        Assert.True({result_var}.Count >= {cs_val});");
820                        }
821                    }
822                    "not_empty" => {
823                        let _ = writeln!(out, "        Assert.NotEmpty({result_var});");
824                    }
825                    "is_empty" => {
826                        let _ = writeln!(out, "        Assert.Empty({result_var});");
827                    }
828                    _ => {
829                        let _ = writeln!(
830                            out,
831                            "        // skipped: unsupported assertion type on synthetic field 'embeddings'"
832                        );
833                    }
834                }
835                return;
836            }
837            "embedding_dimensions" => {
838                let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
839                match assertion.assertion_type.as_str() {
840                    "equals" => {
841                        if let Some(val) = &assertion.value {
842                            let cs_val = json_to_csharp(val);
843                            let _ = writeln!(out, "        Assert.True({expr} == {cs_val});");
844                        }
845                    }
846                    "greater_than" => {
847                        if let Some(val) = &assertion.value {
848                            let cs_val = json_to_csharp(val);
849                            let _ = writeln!(out, "        Assert.True({expr} > {cs_val});");
850                        }
851                    }
852                    _ => {
853                        let _ = writeln!(
854                            out,
855                            "        // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
856                        );
857                    }
858                }
859                return;
860            }
861            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
862                let pred = match f.as_str() {
863                    "embeddings_valid" => {
864                        format!("{result_var}.All(e => e.Count > 0)")
865                    }
866                    "embeddings_finite" => {
867                        format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
868                    }
869                    "embeddings_non_zero" => {
870                        format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
871                    }
872                    "embeddings_normalized" => {
873                        format!(
874                            "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
875                        )
876                    }
877                    _ => unreachable!(),
878                };
879                match assertion.assertion_type.as_str() {
880                    "is_true" => {
881                        let _ = writeln!(out, "        Assert.True({pred});");
882                    }
883                    "is_false" => {
884                        let _ = writeln!(out, "        Assert.False({pred});");
885                    }
886                    _ => {
887                        let _ = writeln!(
888                            out,
889                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
890                        );
891                    }
892                }
893                return;
894            }
895            // ---- keywords / keywords_count ----
896            // C# ExtractionResult does not expose extracted_keywords; skip.
897            "keywords" | "keywords_count" => {
898                let _ = writeln!(
899                    out,
900                    "        // skipped: field '{f}' not available on C# ExtractionResult"
901                );
902                return;
903            }
904            _ => {}
905        }
906    }
907
908    // Skip assertions on fields that don't exist on the result type.
909    if let Some(f) = &assertion.field {
910        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
911            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
912            return;
913        }
914    }
915
916    // When the result is a List<T>, index into the first element for field access.
917    let effective_result_var: String = if result_is_vec {
918        format!("{result_var}[0]")
919    } else {
920        result_var.to_string()
921    };
922
923    let field_expr = if result_is_simple {
924        effective_result_var.clone()
925    } else {
926        match &assertion.field {
927            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
928            _ => effective_result_var.clone(),
929        }
930    };
931
932    match assertion.assertion_type.as_str() {
933        "equals" => {
934            if let Some(expected) = &assertion.value {
935                let cs_val = json_to_csharp(expected);
936                if expected.is_string() {
937                    // Only call .Trim() on string fields.
938                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr}.Trim());");
939                } else if expected.as_bool() == Some(true) {
940                    // Boolean true: use Assert.True to avoid xUnit2004 warning.
941                    let _ = writeln!(out, "        Assert.True({field_expr});");
942                } else if expected.as_bool() == Some(false) {
943                    // Boolean false: use Assert.False to avoid xUnit2004 warning.
944                    let _ = writeln!(out, "        Assert.False({field_expr});");
945                } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
946                    // Integer values: use Assert.True(x == n) to avoid xUnit overload
947                    // resolution ambiguity (int vs uint vs long vs DateTime).
948                    let _ = writeln!(out, "        Assert.True({field_expr} == {cs_val});");
949                } else {
950                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr});");
951                }
952            }
953        }
954        "contains" => {
955            if let Some(expected) = &assertion.value {
956                // Lowercase both expected and actual so that enum fields (where .ToString()
957                // returns the PascalCase C# member name like "Anchor") correctly match
958                // fixture snake_case values like "anchor".  String fields are unaffected
959                // because lowercasing both sides preserves substring matches.
960                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
961                let cs_val = lower_expected
962                    .as_deref()
963                    .map(|s| format!("\"{}\"", escape_csharp(s)))
964                    .unwrap_or_else(|| json_to_csharp(expected));
965                let _ = writeln!(
966                    out,
967                    "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
968                );
969            }
970        }
971        "contains_all" => {
972            if let Some(values) = &assertion.values {
973                for val in values {
974                    let lower_val = val.as_str().map(|s| s.to_lowercase());
975                    let cs_val = lower_val
976                        .as_deref()
977                        .map(|s| format!("\"{}\"", escape_csharp(s)))
978                        .unwrap_or_else(|| json_to_csharp(val));
979                    let _ = writeln!(
980                        out,
981                        "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
982                    );
983                }
984            }
985        }
986        "not_contains" => {
987            if let Some(expected) = &assertion.value {
988                let cs_val = json_to_csharp(expected);
989                let _ = writeln!(out, "        Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
990            }
991        }
992        "not_empty" => {
993            let _ = writeln!(
994                out,
995                "        Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
996            );
997        }
998        "is_empty" => {
999            let _ = writeln!(
1000                out,
1001                "        Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
1002            );
1003        }
1004        "contains_any" => {
1005            if let Some(values) = &assertion.values {
1006                let checks: Vec<String> = values
1007                    .iter()
1008                    .map(|v| {
1009                        let cs_val = json_to_csharp(v);
1010                        format!("{field_expr}.ToString().Contains({cs_val})")
1011                    })
1012                    .collect();
1013                let joined = checks.join(" || ");
1014                let _ = writeln!(
1015                    out,
1016                    "        Assert.True({joined}, \"expected to contain at least one of the specified values\");"
1017                );
1018            }
1019        }
1020        "greater_than" => {
1021            if let Some(val) = &assertion.value {
1022                let cs_val = json_to_csharp(val);
1023                let _ = writeln!(
1024                    out,
1025                    "        Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
1026                );
1027            }
1028        }
1029        "less_than" => {
1030            if let Some(val) = &assertion.value {
1031                let cs_val = json_to_csharp(val);
1032                let _ = writeln!(
1033                    out,
1034                    "        Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
1035                );
1036            }
1037        }
1038        "greater_than_or_equal" => {
1039            if let Some(val) = &assertion.value {
1040                let cs_val = json_to_csharp(val);
1041                let _ = writeln!(
1042                    out,
1043                    "        Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1044                );
1045            }
1046        }
1047        "less_than_or_equal" => {
1048            if let Some(val) = &assertion.value {
1049                let cs_val = json_to_csharp(val);
1050                let _ = writeln!(
1051                    out,
1052                    "        Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
1053                );
1054            }
1055        }
1056        "starts_with" => {
1057            if let Some(expected) = &assertion.value {
1058                let cs_val = json_to_csharp(expected);
1059                let _ = writeln!(out, "        Assert.StartsWith({cs_val}, {field_expr});");
1060            }
1061        }
1062        "ends_with" => {
1063            if let Some(expected) = &assertion.value {
1064                let cs_val = json_to_csharp(expected);
1065                let _ = writeln!(out, "        Assert.EndsWith({cs_val}, {field_expr});");
1066            }
1067        }
1068        "min_length" => {
1069            if let Some(val) = &assertion.value {
1070                if let Some(n) = val.as_u64() {
1071                    let _ = writeln!(
1072                        out,
1073                        "        Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
1074                    );
1075                }
1076            }
1077        }
1078        "max_length" => {
1079            if let Some(val) = &assertion.value {
1080                if let Some(n) = val.as_u64() {
1081                    let _ = writeln!(
1082                        out,
1083                        "        Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
1084                    );
1085                }
1086            }
1087        }
1088        "count_min" => {
1089            if let Some(val) = &assertion.value {
1090                if let Some(n) = val.as_u64() {
1091                    let _ = writeln!(
1092                        out,
1093                        "        Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
1094                    );
1095                }
1096            }
1097        }
1098        "count_equals" => {
1099            if let Some(val) = &assertion.value {
1100                if let Some(n) = val.as_u64() {
1101                    let _ = writeln!(out, "        Assert.Equal({n}, {field_expr}.Count);");
1102                }
1103            }
1104        }
1105        "is_true" => {
1106            let _ = writeln!(out, "        Assert.True({field_expr});");
1107        }
1108        "is_false" => {
1109            let _ = writeln!(out, "        Assert.False({field_expr});");
1110        }
1111        "not_error" => {
1112            // Already handled by the call succeeding without exception.
1113        }
1114        "error" => {
1115            // Handled at the test method level.
1116        }
1117        "method_result" => {
1118            if let Some(method_name) = &assertion.method {
1119                let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
1120                let check = assertion.check.as_deref().unwrap_or("is_true");
1121                match check {
1122                    "equals" => {
1123                        if let Some(val) = &assertion.value {
1124                            if val.as_bool() == Some(true) {
1125                                let _ = writeln!(out, "        Assert.True({call_expr});");
1126                            } else if val.as_bool() == Some(false) {
1127                                let _ = writeln!(out, "        Assert.False({call_expr});");
1128                            } else {
1129                                let cs_val = json_to_csharp(val);
1130                                let _ = writeln!(out, "        Assert.Equal({cs_val}, {call_expr});");
1131                            }
1132                        }
1133                    }
1134                    "is_true" => {
1135                        let _ = writeln!(out, "        Assert.True({call_expr});");
1136                    }
1137                    "is_false" => {
1138                        let _ = writeln!(out, "        Assert.False({call_expr});");
1139                    }
1140                    "greater_than_or_equal" => {
1141                        if let Some(val) = &assertion.value {
1142                            let n = val.as_u64().unwrap_or(0);
1143                            let _ = writeln!(out, "        Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
1144                        }
1145                    }
1146                    "count_min" => {
1147                        if let Some(val) = &assertion.value {
1148                            let n = val.as_u64().unwrap_or(0);
1149                            let _ = writeln!(
1150                                out,
1151                                "        Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
1152                            );
1153                        }
1154                    }
1155                    "is_error" => {
1156                        let _ = writeln!(
1157                            out,
1158                            "        Assert.Throws<{exception_class}>(() => {{ {call_expr}; }});"
1159                        );
1160                    }
1161                    "contains" => {
1162                        if let Some(val) = &assertion.value {
1163                            let cs_val = json_to_csharp(val);
1164                            let _ = writeln!(out, "        Assert.Contains({cs_val}, {call_expr});");
1165                        }
1166                    }
1167                    other_check => {
1168                        panic!("C# e2e generator: unsupported method_result check type: {other_check}");
1169                    }
1170                }
1171            } else {
1172                panic!("C# e2e generator: method_result assertion missing 'method' field");
1173            }
1174        }
1175        "matches_regex" => {
1176            if let Some(expected) = &assertion.value {
1177                let cs_val = json_to_csharp(expected);
1178                let _ = writeln!(out, "        Assert.Matches({cs_val}, {field_expr});");
1179            }
1180        }
1181        other => {
1182            panic!("C# e2e generator: unsupported assertion type: {other}");
1183        }
1184    }
1185}
1186
1187/// Recursively sort JSON objects so that any key named `"type"` appears first.
1188///
1189/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
1190/// the first property when deserializing polymorphic types. Fixture config values
1191/// serialised via serde_json preserve insertion/alphabetical order, which may put
1192/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
1193fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
1194    match value {
1195        serde_json::Value::Object(map) => {
1196            let mut sorted = serde_json::Map::with_capacity(map.len());
1197            // Insert "type" first if present.
1198            if let Some(type_val) = map.get("type") {
1199                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
1200            }
1201            for (k, v) in map {
1202                if k != "type" {
1203                    sorted.insert(k, sort_discriminator_first(v));
1204                }
1205            }
1206            serde_json::Value::Object(sorted)
1207        }
1208        serde_json::Value::Array(arr) => {
1209            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
1210        }
1211        other => other,
1212    }
1213}
1214
1215/// Convert a `serde_json::Value` to a C# literal string.
1216fn json_to_csharp(value: &serde_json::Value) -> String {
1217    match value {
1218        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
1219        serde_json::Value::Bool(true) => "true".to_string(),
1220        serde_json::Value::Bool(false) => "false".to_string(),
1221        serde_json::Value::Number(n) => {
1222            if n.is_f64() {
1223                format!("{}d", n)
1224            } else {
1225                n.to_string()
1226            }
1227        }
1228        serde_json::Value::Null => "null".to_string(),
1229        serde_json::Value::Array(arr) => {
1230            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1231            format!("new[] {{ {} }}", items.join(", "))
1232        }
1233        serde_json::Value::Object(_) => {
1234            let json_str = serde_json::to_string(value).unwrap_or_default();
1235            format!("\"{}\"", escape_csharp(&json_str))
1236        }
1237    }
1238}
1239
1240// ---------------------------------------------------------------------------
1241// Visitor generation
1242// ---------------------------------------------------------------------------
1243
1244/// Build a C# visitor class and add setup lines. Returns the visitor variable name.
1245fn build_csharp_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1246    setup_lines.push("var _testVisitor = new TestVisitor();".to_string());
1247    setup_lines.push("class TestVisitor : IVisitor".to_string());
1248    setup_lines.push("{".to_string());
1249    for (method_name, action) in &visitor_spec.callbacks {
1250        emit_csharp_visitor_method(setup_lines, method_name, action);
1251    }
1252    setup_lines.push("}".to_string());
1253    "_testVisitor".to_string()
1254}
1255
1256/// Emit a C# visitor method for a callback action.
1257fn emit_csharp_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1258    let camel_method = method_to_camel(method_name);
1259    let params = match method_name {
1260        "visit_link" => "VisitContext ctx, string href, string text, string title",
1261        "visit_image" => "VisitContext ctx, string src, string alt, string title",
1262        "visit_heading" => "VisitContext ctx, int level, string text, string id",
1263        "visit_code_block" => "VisitContext ctx, string lang, string code",
1264        "visit_code_inline"
1265        | "visit_strong"
1266        | "visit_emphasis"
1267        | "visit_strikethrough"
1268        | "visit_underline"
1269        | "visit_subscript"
1270        | "visit_superscript"
1271        | "visit_mark"
1272        | "visit_button"
1273        | "visit_summary"
1274        | "visit_figcaption"
1275        | "visit_definition_term"
1276        | "visit_definition_description" => "VisitContext ctx, string text",
1277        "visit_text" => "VisitContext ctx, string text",
1278        "visit_list_item" => "VisitContext ctx, bool ordered, string marker, string text",
1279        "visit_blockquote" => "VisitContext ctx, string content, int depth",
1280        "visit_table_row" => "VisitContext ctx, IReadOnlyList<string> cells, bool isHeader",
1281        "visit_custom_element" => "VisitContext ctx, string tagName, string html",
1282        "visit_form" => "VisitContext ctx, string actionUrl, string method",
1283        "visit_input" => "VisitContext ctx, string inputType, string name, string value",
1284        "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, string src",
1285        "visit_details" => "VisitContext ctx, bool isOpen",
1286        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1287            "VisitContext ctx, string output"
1288        }
1289        "visit_list_start" => "VisitContext ctx, bool ordered",
1290        "visit_list_end" => "VisitContext ctx, bool ordered, string output",
1291        _ => "VisitContext ctx",
1292    };
1293
1294    setup_lines.push(format!("    public VisitResult {camel_method}({params})"));
1295    setup_lines.push("    {".to_string());
1296    match action {
1297        CallbackAction::Skip => {
1298            setup_lines.push("        return VisitResult.Skip();".to_string());
1299        }
1300        CallbackAction::Continue => {
1301            setup_lines.push("        return VisitResult.Continue();".to_string());
1302        }
1303        CallbackAction::PreserveHtml => {
1304            setup_lines.push("        return VisitResult.PreserveHtml();".to_string());
1305        }
1306        CallbackAction::Custom { output } => {
1307            let escaped = escape_csharp(output);
1308            setup_lines.push(format!("        return VisitResult.Custom(\"{escaped}\");"));
1309        }
1310        CallbackAction::CustomTemplate { template } => {
1311            let escaped = escape_csharp(template);
1312            setup_lines.push(format!("        return VisitResult.Custom($\"{escaped}\");"));
1313        }
1314    }
1315    setup_lines.push("    }".to_string());
1316}
1317
1318/// Convert snake_case method names to C# PascalCase.
1319fn method_to_camel(snake: &str) -> String {
1320    use heck::ToUpperCamelCase;
1321    snake.to_upper_camel_case()
1322}
1323
1324/// Build a C# call expression for a `method_result` assertion on a tree-sitter Tree.
1325///
1326/// Maps well-known method names to the appropriate C# static helper calls on the
1327/// generated lib class, falling back to `result_var.PascalCase()` for unknowns.
1328fn build_csharp_method_call(
1329    result_var: &str,
1330    method_name: &str,
1331    args: Option<&serde_json::Value>,
1332    class_name: &str,
1333) -> String {
1334    match method_name {
1335        "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
1336        "root_node_type" => format!("{result_var}.RootNode.Kind"),
1337        "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
1338        "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
1339        "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
1340        "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
1341        "contains_node_type" => {
1342            let node_type = args
1343                .and_then(|a| a.get("node_type"))
1344                .and_then(|v| v.as_str())
1345                .unwrap_or("");
1346            format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
1347        }
1348        "find_nodes_by_type" => {
1349            let node_type = args
1350                .and_then(|a| a.get("node_type"))
1351                .and_then(|v| v.as_str())
1352                .unwrap_or("");
1353            format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
1354        }
1355        "run_query" => {
1356            let query_source = args
1357                .and_then(|a| a.get("query_source"))
1358                .and_then(|v| v.as_str())
1359                .unwrap_or("");
1360            let language = args
1361                .and_then(|a| a.get("language"))
1362                .and_then(|v| v.as_str())
1363                .unwrap_or("");
1364            format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1365        }
1366        _ => {
1367            use heck::ToUpperCamelCase;
1368            let pascal = method_name.to_upper_camel_case();
1369            format!("{result_var}.{pascal}()")
1370        }
1371    }
1372}