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, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
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;
21use super::client;
22
23/// C# e2e code generator.
24pub struct CSharpCodegen;
25
26impl E2eCodegen for CSharpCodegen {
27    fn generate(
28        &self,
29        groups: &[FixtureGroup],
30        e2e_config: &E2eConfig,
31        alef_config: &AlefConfig,
32    ) -> Result<Vec<GeneratedFile>> {
33        let lang = self.language_name();
34        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36        let mut files = Vec::new();
37
38        // Resolve call config with overrides.
39        let call = &e2e_config.call;
40        let overrides = call.overrides.get(lang);
41        let function_name = overrides
42            .and_then(|o| o.function.as_ref())
43            .cloned()
44            .unwrap_or_else(|| call.function.to_upper_camel_case());
45        let class_name = overrides
46            .and_then(|o| o.class.as_ref())
47            .cloned()
48            .unwrap_or_else(|| format!("{}Lib", alef_config.crate_config.name.to_upper_camel_case()));
49        // The exception class is always {CrateName}Exception, generated by the C# backend.
50        let exception_class = format!("{}Exception", alef_config.crate_config.name.to_upper_camel_case());
51        let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
52            if call.module.is_empty() {
53                "Kreuzberg".to_string()
54            } else {
55                call.module.to_upper_camel_case()
56            }
57        });
58        let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
59        let result_var = &call.result_var;
60        let is_async = call.r#async;
61
62        // Resolve package config.
63        let cs_pkg = e2e_config.resolve_package("csharp");
64        let pkg_name = cs_pkg
65            .as_ref()
66            .and_then(|p| p.name.as_ref())
67            .cloned()
68            .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
69        // The project reference path uses the crate name (with hyphens) for the directory
70        // and the PascalCase name for the .csproj file.
71        let pkg_path = cs_pkg
72            .as_ref()
73            .and_then(|p| p.path.as_ref())
74            .cloned()
75            .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}.csproj"));
76        let pkg_version = cs_pkg
77            .as_ref()
78            .and_then(|p| p.version.as_ref())
79            .cloned()
80            .unwrap_or_else(|| "0.1.0".to_string());
81
82        // Generate the .csproj using a unique name derived from the package name so
83        // it does not conflict with any hand-written project files in the same directory.
84        let csproj_name = format!("{pkg_name}.E2eTests.csproj");
85        files.push(GeneratedFile {
86            path: output_base.join(&csproj_name),
87            content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
88            generated_header: false,
89        });
90
91        // Generate test files per category.
92        let tests_base = output_base.join("tests");
93        let field_resolver = FieldResolver::new(
94            &e2e_config.fields,
95            &e2e_config.fields_optional,
96            &e2e_config.result_fields,
97            &e2e_config.fields_array,
98        );
99
100        // Resolve enum_fields from C# override config.
101        static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
102        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
103
104        for group in groups {
105            let active: Vec<&Fixture> = group
106                .fixtures
107                .iter()
108                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
109                .collect();
110
111            if active.is_empty() {
112                continue;
113            }
114
115            let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
116            let filename = format!("{test_class}.cs");
117            let content = render_test_file(
118                &group.category,
119                &active,
120                &namespace,
121                &class_name,
122                &function_name,
123                &exception_class,
124                result_var,
125                &test_class,
126                &e2e_config.call.args,
127                &field_resolver,
128                result_is_simple,
129                is_async,
130                e2e_config,
131                enum_fields,
132            );
133            files.push(GeneratedFile {
134                path: tests_base.join(filename),
135                content,
136                generated_header: true,
137            });
138        }
139
140        Ok(files)
141    }
142
143    fn language_name(&self) -> &'static str {
144        "csharp"
145    }
146}
147
148// ---------------------------------------------------------------------------
149// Rendering
150// ---------------------------------------------------------------------------
151
152fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
153    let pkg_ref = match dep_mode {
154        crate::config::DependencyMode::Registry => {
155            format!("    <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
156        }
157        crate::config::DependencyMode::Local => {
158            format!("    <ProjectReference Include=\"{pkg_path}\" />")
159        }
160    };
161    format!(
162        r#"<Project Sdk="Microsoft.NET.Sdk">
163  <PropertyGroup>
164    <TargetFramework>net10.0</TargetFramework>
165    <Nullable>enable</Nullable>
166    <ImplicitUsings>enable</ImplicitUsings>
167    <IsPackable>false</IsPackable>
168    <IsTestProject>true</IsTestProject>
169  </PropertyGroup>
170
171  <ItemGroup>
172    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="{ms_test_sdk}" />
173    <PackageReference Include="xunit" Version="{xunit}" />
174    <PackageReference Include="xunit.runner.visualstudio" Version="{xunit_runner}" />
175  </ItemGroup>
176
177  <ItemGroup>
178{pkg_ref}
179  </ItemGroup>
180</Project>
181"#,
182        ms_test_sdk = tv::nuget::MICROSOFT_NET_TEST_SDK,
183        xunit = tv::nuget::XUNIT,
184        xunit_runner = tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
185    )
186}
187
188#[allow(clippy::too_many_arguments)]
189fn render_test_file(
190    category: &str,
191    fixtures: &[&Fixture],
192    namespace: &str,
193    class_name: &str,
194    function_name: &str,
195    exception_class: &str,
196    result_var: &str,
197    test_class: &str,
198    args: &[crate::config::ArgMapping],
199    field_resolver: &FieldResolver,
200    result_is_simple: bool,
201    is_async: bool,
202    e2e_config: &E2eConfig,
203    enum_fields: &HashMap<String, String>,
204) -> String {
205    let mut out = String::new();
206    out.push_str(&hash::header(CommentStyle::DoubleSlash));
207    // Always import System.Text.Json for the shared JsonOptions field.
208    let _ = writeln!(out, "using System;");
209    let _ = writeln!(out, "using System.Collections.Generic;");
210    let _ = writeln!(out, "using System.Linq;");
211    let _ = writeln!(out, "using System.Net.Http;");
212    let _ = writeln!(out, "using System.Text;");
213    let _ = writeln!(out, "using System.Text.Json;");
214    let _ = writeln!(out, "using System.Text.Json.Serialization;");
215    let _ = writeln!(out, "using System.Threading.Tasks;");
216    let _ = writeln!(out, "using Xunit;");
217    let _ = writeln!(out, "using {namespace};");
218    let _ = writeln!(out);
219    let _ = writeln!(out, "namespace Kreuzberg.E2e;");
220    let _ = writeln!(out);
221    let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
222    let _ = writeln!(out, "public class {test_class}");
223    let _ = writeln!(out, "{{");
224    // Shared options used when deserializing config JSON in test setup.
225    // Mirrors the options used by the library to ensure enum values round-trip correctly.
226    let _ = writeln!(
227        out,
228        "    private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
229    );
230    let _ = writeln!(out);
231
232    // Visitor class declarations accumulated across all fixtures — emitted as
233    // private nested classes inside the test class but outside any method body.
234    // C# does not allow local class declarations inside method bodies.
235    let mut visitor_class_decls: Vec<String> = Vec::new();
236
237    for (i, fixture) in fixtures.iter().enumerate() {
238        render_test_method(
239            &mut out,
240            &mut visitor_class_decls,
241            fixture,
242            class_name,
243            function_name,
244            exception_class,
245            result_var,
246            args,
247            field_resolver,
248            result_is_simple,
249            is_async,
250            e2e_config,
251            enum_fields,
252        );
253        if i + 1 < fixtures.len() {
254            let _ = writeln!(out);
255        }
256    }
257
258    // Emit visitor helper classes at class scope (after test methods).
259    for decl in &visitor_class_decls {
260        let _ = writeln!(out);
261        let _ = writeln!(out, "{decl}");
262    }
263
264    let _ = writeln!(out, "}}");
265    out
266}
267
268// ---------------------------------------------------------------------------
269// HTTP test rendering — shared-driver integration
270// ---------------------------------------------------------------------------
271
272/// Renderer that emits xUnit `[Fact] public async Task Test_*()` methods using
273/// `System.Net.Http.HttpClient` against the mock server at `MOCK_SERVER_URL`.
274/// Satisfies [`client::TestClientRenderer`] so the shared
275/// [`client::http_call::render_http_test`] driver drives the call sequence.
276struct CSharpTestClientRenderer;
277
278/// C# HttpMethod static properties are PascalCase (Get, Post, Put, Delete, …).
279fn to_csharp_http_method(method: &str) -> String {
280    let lower = method.to_ascii_lowercase();
281    let mut chars = lower.chars();
282    match chars.next() {
283        Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
284        None => String::new(),
285    }
286}
287
288/// Headers that belong to `request.Content.Headers` rather than `request.Headers`.
289///
290/// Adding these to `request.Headers` causes .NET to throw "Misused header name".
291const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
292    "content-length",
293    "host",
294    "connection",
295    "expect",
296    "transfer-encoding",
297    "upgrade",
298    // Content-Type is owned by request.Content.Headers and is set when
299    // StringContent is constructed; adding it to request.Headers throws.
300    "content-type",
301    // Other entity headers also belong to request.Content.Headers.
302    "content-encoding",
303    "content-language",
304    "content-location",
305    "content-md5",
306    "content-range",
307    "content-disposition",
308];
309
310/// Whether `name` (any case) belongs to `response.Content.Headers` rather than
311/// `response.Headers`. Picking the wrong collection causes .NET to throw
312/// "Misused header name".
313fn is_csharp_content_header(name: &str) -> bool {
314    matches!(
315        name.to_ascii_lowercase().as_str(),
316        "content-type"
317            | "content-length"
318            | "content-encoding"
319            | "content-language"
320            | "content-location"
321            | "content-md5"
322            | "content-range"
323            | "content-disposition"
324            | "expires"
325            | "last-modified"
326            | "allow"
327    )
328}
329
330impl client::TestClientRenderer for CSharpTestClientRenderer {
331    fn language_name(&self) -> &'static str {
332        "csharp"
333    }
334
335    /// Convert a fixture id to the PascalCase identifier used in `Test_{name}`.
336    fn sanitize_test_name(&self, id: &str) -> String {
337        id.to_upper_camel_case()
338    }
339
340    /// Emit `[Fact]` (or `[Fact(Skip = "…")]` for skipped tests), the method
341    /// signature, the opening brace, and the description comment.
342    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
343        if let Some(reason) = skip_reason {
344            let escaped_reason = escape_csharp(reason);
345            let _ = writeln!(out, "    [Fact(Skip = \"{escaped_reason}\")]");
346            let _ = writeln!(out, "    public async Task Test_{fn_name}()");
347        } else {
348            let _ = writeln!(out, "    [Fact]");
349            let _ = writeln!(out, "    public async Task Test_{fn_name}()");
350        }
351        let _ = writeln!(out, "    {{");
352        let _ = writeln!(out, "        // {description}");
353    }
354
355    /// Emit the closing `}` for a test method.
356    fn render_test_close(&self, out: &mut String) {
357        let _ = writeln!(out, "    }}");
358    }
359
360    /// Emit the `HttpRequestMessage` construction, headers, cookies, body, and
361    /// `var response = await client.SendAsync(request)`.
362    ///
363    /// The fixture path follows the mock-server convention `/fixtures/<id>`.
364    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
365        let method = to_csharp_http_method(ctx.method);
366        let path = escape_csharp(ctx.path);
367
368        let _ = writeln!(
369            out,
370            "        var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";"
371        );
372        // Disable auto-follow so redirect-status fixtures (3xx) can assert the
373        // server's status code rather than the followed-target's status.
374        let _ = writeln!(
375            out,
376            "        using var handler = new System.Net.Http.HttpClientHandler {{ AllowAutoRedirect = false }};"
377        );
378        let _ = writeln!(
379            out,
380            "        using var client = new System.Net.Http.HttpClient(handler);"
381        );
382        let _ = writeln!(
383            out,
384            "        var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}{path}\");"
385        );
386
387        // Set body + Content-Type when a request body is present.
388        if let Some(body) = ctx.body {
389            let content_type = ctx.content_type.unwrap_or("application/json");
390            let json_str = serde_json::to_string(body).unwrap_or_default();
391            let escaped = escape_csharp(&json_str);
392            let _ = writeln!(
393                out,
394                "        request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");"
395            );
396        }
397
398        // Add request headers (skip restricted headers that belong to Content.Headers).
399        for (name, value) in ctx.headers {
400            if CSHARP_RESTRICTED_REQUEST_HEADERS.contains(&name.to_lowercase().as_str()) {
401                continue;
402            }
403            let escaped_name = escape_csharp(name);
404            let escaped_value = escape_csharp(value);
405            let _ = writeln!(
406                out,
407                "        request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");"
408            );
409        }
410
411        // Combine cookies into a single `Cookie` header.
412        if !ctx.cookies.is_empty() {
413            let mut pairs: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
414            pairs.sort();
415            let cookie_header = escape_csharp(&pairs.join("; "));
416            let _ = writeln!(out, "        request.Headers.Add(\"Cookie\", \"{cookie_header}\");");
417        }
418
419        let _ = writeln!(out, "        var response = await client.SendAsync(request);");
420    }
421
422    /// Emit `Assert.Equal(status, (int)response.StatusCode)`.
423    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
424        let _ = writeln!(out, "        Assert.Equal({status}, (int)response.StatusCode);");
425    }
426
427    /// Emit a response-header assertion.
428    ///
429    /// Handles special tokens: `<<present>>`, `<<absent>>`, `<<uuid>>`.
430    /// Picks `response.Content.Headers` vs `response.Headers` based on the header name.
431    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
432        let target = if is_csharp_content_header(name) {
433            "response.Content.Headers"
434        } else {
435            "response.Headers"
436        };
437        let escaped_name = escape_csharp(name);
438        match expected {
439            "<<present>>" => {
440                let _ = writeln!(
441                    out,
442                    "        Assert.True({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be present\");"
443                );
444            }
445            "<<absent>>" => {
446                let _ = writeln!(
447                    out,
448                    "        Assert.False({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be absent\");"
449                );
450            }
451            "<<uuid>>" => {
452                // UUID regex: 8-4-4-4-12 hex groups.
453                let _ = writeln!(
454                    out,
455                    "        Assert.True({target}.TryGetValues(\"{escaped_name}\", out var _uuidHdr) && System.Text.RegularExpressions.Regex.IsMatch(string.Join(\", \", _uuidHdr), @\"^[0-9a-fA-F]{{8}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{12}}$\"), \"header {escaped_name} is not a UUID\");"
456                );
457            }
458            literal => {
459                // Use a deterministic local-variable name derived from the header name so
460                // multiple header assertions in the same method body do not redeclare.
461                let var_name = format!("hdr{}", sanitize_ident(name));
462                let escaped_value = escape_csharp(literal);
463                let _ = writeln!(
464                    out,
465                    "        Assert.True({target}.TryGetValues(\"{escaped_name}\", out var {var_name}) && {var_name}.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");"
466                );
467            }
468        }
469    }
470
471    /// Emit a JSON body equality assertion via `JsonDocument`.
472    ///
473    /// Plain-string bodies are compared with `Assert.Equal` after trimming.
474    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
475        match expected {
476            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
477                let json_str = serde_json::to_string(expected).unwrap_or_default();
478                let escaped = escape_csharp(&json_str);
479                let _ = writeln!(
480                    out,
481                    "        var bodyText = await response.Content.ReadAsStringAsync();"
482                );
483                let _ = writeln!(out, "        var body = JsonDocument.Parse(bodyText).RootElement;");
484                let _ = writeln!(
485                    out,
486                    "        var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;"
487                );
488                let _ = writeln!(
489                    out,
490                    "        Assert.Equal(expectedBody.GetRawText(), body.GetRawText());"
491                );
492            }
493            serde_json::Value::String(s) => {
494                let escaped = escape_csharp(s);
495                let _ = writeln!(
496                    out,
497                    "        var bodyText = await response.Content.ReadAsStringAsync();"
498                );
499                let _ = writeln!(out, "        Assert.Equal(\"{escaped}\", bodyText.Trim());");
500            }
501            other => {
502                let escaped = escape_csharp(&other.to_string());
503                let _ = writeln!(
504                    out,
505                    "        var bodyText = await response.Content.ReadAsStringAsync();"
506                );
507                let _ = writeln!(out, "        Assert.Equal(\"{escaped}\", bodyText.Trim());");
508            }
509        }
510    }
511
512    /// Emit per-field equality assertions for a partial body match.
513    ///
514    /// Uses a separate `partialBodyText` local so it does not collide with
515    /// `bodyText` if `render_assert_json_body` was also called.
516    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
517        if let Some(obj) = expected.as_object() {
518            let _ = writeln!(
519                out,
520                "        var partialBodyText = await response.Content.ReadAsStringAsync();"
521            );
522            let _ = writeln!(
523                out,
524                "        var partialBody = JsonDocument.Parse(partialBodyText).RootElement;"
525            );
526            for (key, val) in obj {
527                let escaped_key = escape_csharp(key);
528                let json_str = serde_json::to_string(val).unwrap_or_default();
529                let escaped_val = escape_csharp(&json_str);
530                let var_name = format!("expected{}", key.to_upper_camel_case());
531                let _ = writeln!(
532                    out,
533                    "        var {var_name} = JsonDocument.Parse(\"{escaped_val}\").RootElement;"
534                );
535                let _ = writeln!(
536                    out,
537                    "        Assert.True(partialBody.TryGetProperty(\"{escaped_key}\", out var _partialProp{var_name}) && _partialProp{var_name}.GetRawText() == {var_name}.GetRawText(), \"partial body field '{escaped_key}' mismatch\");"
538                );
539            }
540        }
541    }
542
543    /// Emit validation-error assertions by checking each expected `msg` string
544    /// appears in the JSON-encoded body.
545    fn render_assert_validation_errors(
546        &self,
547        out: &mut String,
548        _response_var: &str,
549        errors: &[ValidationErrorExpectation],
550    ) {
551        let _ = writeln!(
552            out,
553            "        var validationBodyText = await response.Content.ReadAsStringAsync();"
554        );
555        for err in errors {
556            let escaped_msg = escape_csharp(&err.msg);
557            let _ = writeln!(out, "        Assert.Contains(\"{escaped_msg}\", validationBodyText);");
558        }
559    }
560}
561
562/// Render an HTTP server test method using the shared [`client::http_call::render_http_test`]
563/// driver via [`CSharpTestClientRenderer`].
564fn render_http_test_method(out: &mut String, fixture: &Fixture, _http: &HttpFixture) {
565    client::http_call::render_http_test(out, &CSharpTestClientRenderer, fixture);
566}
567
568#[allow(clippy::too_many_arguments)]
569fn render_test_method(
570    out: &mut String,
571    visitor_class_decls: &mut Vec<String>,
572    fixture: &Fixture,
573    class_name: &str,
574    _function_name: &str,
575    exception_class: &str,
576    _result_var: &str,
577    _args: &[crate::config::ArgMapping],
578    field_resolver: &FieldResolver,
579    result_is_simple: bool,
580    _is_async: bool,
581    e2e_config: &E2eConfig,
582    enum_fields: &HashMap<String, String>,
583) {
584    let method_name = fixture.id.to_upper_camel_case();
585    let description = &fixture.description;
586
587    // HTTP fixtures: generate real HTTP client tests using System.Net.Http.
588    if let Some(http) = &fixture.http {
589        render_http_test_method(out, fixture, http);
590        return;
591    }
592
593    // Non-HTTP fixtures with no mock_response: the C# binding wraps a C FFI
594    // layer and does not expose a HandleRequest callable. Emit a documented
595    // skip so the project stays compilable.
596    if fixture.mock_response.is_none() {
597        let _ = writeln!(
598            out,
599            "    [Fact(Skip = \"non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function\")]"
600        );
601        let _ = writeln!(out, "    public void Test_{method_name}()");
602        let _ = writeln!(out, "    {{");
603        let _ = writeln!(out, "        // {description}");
604        let _ = writeln!(out, "    }}");
605        return;
606    }
607
608    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
609
610    // Resolve call config per-fixture so named calls (e.g. "parse") use the
611    // correct function name, result variable, and async flag.
612    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
613    let lang = "csharp";
614    let cs_overrides = call_config.overrides.get(lang);
615    let effective_function_name = cs_overrides
616        .and_then(|o| o.function.as_ref())
617        .cloned()
618        .unwrap_or_else(|| call_config.function.to_upper_camel_case());
619    let effective_result_var = &call_config.result_var;
620    let effective_is_async = call_config.r#async;
621    let function_name = effective_function_name.as_str();
622    let result_var = effective_result_var.as_str();
623    let is_async = effective_is_async;
624    let args = call_config.args.as_slice();
625
626    // Per-call overrides: result shape, void returns, extra trailing args.
627    // Pull `result_is_simple` from the per-call config first (call-level value
628    // wins, then per-language override, then the top-level call's value).
629    let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
630    let effective_result_is_simple = result_is_simple || per_call_result_is_simple;
631    let returns_void = call_config.returns_void;
632    let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
633    // options_type: prefer per-call override, fall back to top-level csharp override.
634    let top_level_options_type = e2e_config
635        .call
636        .overrides
637        .get("csharp")
638        .and_then(|o| o.options_type.as_deref());
639    let effective_options_type = cs_overrides
640        .and_then(|o| o.options_type.as_deref())
641        .or(top_level_options_type);
642
643    let (mut setup_lines, args_str) = build_args_and_setup(
644        &fixture.input,
645        args,
646        class_name,
647        effective_options_type,
648        enum_fields,
649        &fixture.id,
650    );
651
652    // Build visitor if present: instantiate in method body, declare class at file scope.
653    let mut visitor_arg = String::new();
654    if let Some(visitor_spec) = &fixture.visitor {
655        visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_class_decls, &fixture.id, visitor_spec);
656    }
657
658    let args_with_visitor = if visitor_arg.is_empty() {
659        args_str
660    } else {
661        format!("{args_str}, {visitor_arg}")
662    };
663
664    let final_args = if extra_args_slice.is_empty() {
665        args_with_visitor
666    } else if args_with_visitor.is_empty() {
667        extra_args_slice.join(", ")
668    } else {
669        format!("{args_with_visitor}, {}", extra_args_slice.join(", "))
670    };
671
672    let return_type = if is_async { "async Task" } else { "void" };
673    let await_kw = if is_async { "await " } else { "" };
674
675    let _ = writeln!(out, "    [Fact]");
676    let _ = writeln!(out, "    public {return_type} Test_{method_name}()");
677    let _ = writeln!(out, "    {{");
678    let _ = writeln!(out, "        // {description}");
679
680    for line in &setup_lines {
681        let _ = writeln!(out, "        {line}");
682    }
683
684    if expects_error {
685        if is_async {
686            let _ = writeln!(
687                out,
688                "        await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
689            );
690        } else {
691            let _ = writeln!(
692                out,
693                "        Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
694            );
695        }
696        let _ = writeln!(out, "    }}");
697        return;
698    }
699
700    let result_is_vec = call_config.result_is_vec || cs_overrides.is_some_and(|o| o.result_is_vec);
701
702    if returns_void {
703        let _ = writeln!(out, "        {await_kw}{class_name}.{function_name}({final_args});");
704    } else {
705        let _ = writeln!(
706            out,
707            "        var {result_var} = {await_kw}{class_name}.{function_name}({final_args});"
708        );
709        for assertion in &fixture.assertions {
710            render_assertion(
711                out,
712                assertion,
713                result_var,
714                class_name,
715                exception_class,
716                field_resolver,
717                effective_result_is_simple,
718                result_is_vec,
719            );
720        }
721    }
722
723    let _ = writeln!(out, "    }}");
724}
725
726/// Build setup lines (e.g. handle creation) and the argument list for the function call.
727///
728/// Returns `(setup_lines, args_string)`.
729fn build_args_and_setup(
730    input: &serde_json::Value,
731    args: &[crate::config::ArgMapping],
732    class_name: &str,
733    options_type: Option<&str>,
734    _enum_fields: &HashMap<String, String>,
735    fixture_id: &str,
736) -> (Vec<String>, String) {
737    if args.is_empty() {
738        return (Vec::new(), String::new());
739    }
740
741    let mut setup_lines: Vec<String> = Vec::new();
742    let mut parts: Vec<String> = Vec::new();
743
744    for arg in args {
745        if arg.arg_type == "bytes" {
746            // bytes args must be passed as byte[] in C#.
747            // Treat the fixture value as a UTF-8 string and convert to bytes.
748            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
749            let val = input.get(field);
750            match val {
751                None | Some(serde_json::Value::Null) if arg.optional => {
752                    parts.push("null".to_string());
753                }
754                None | Some(serde_json::Value::Null) => {
755                    parts.push("System.Array.Empty<byte>()".to_string());
756                }
757                Some(v) => {
758                    let cs_str = json_to_csharp(v);
759                    parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
760                }
761            }
762            continue;
763        }
764
765        if arg.arg_type == "mock_url" {
766            setup_lines.push(format!(
767                "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
768                arg.name,
769            ));
770            parts.push(arg.name.clone());
771            continue;
772        }
773
774        if arg.arg_type == "handle" {
775            // Generate a CreateEngine (or equivalent) call and pass the variable.
776            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
777            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
778            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
779            if config_value.is_null()
780                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
781            {
782                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
783            } else {
784                // Sort discriminator fields ("type") to appear first in nested objects so
785                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
786                // reading other properties (a requirement as of .NET 8).
787                let sorted = sort_discriminator_first(config_value.clone());
788                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
789                let name = &arg.name;
790                setup_lines.push(format!(
791                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
792                    escape_csharp(&json_str),
793                ));
794                setup_lines.push(format!(
795                    "var {} = {class_name}.{constructor_name}({name}Config);",
796                    arg.name,
797                    name = name,
798                ));
799            }
800            parts.push(arg.name.clone());
801            continue;
802        }
803
804        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
805        let val = input.get(field);
806        match val {
807            None | Some(serde_json::Value::Null) if arg.optional => {
808                // Optional arg with no fixture value: pass null explicitly since
809                // C# nullable parameters still require an argument at the call site.
810                parts.push("null".to_string());
811                continue;
812            }
813            None | Some(serde_json::Value::Null) => {
814                // Required arg with no fixture value: pass a language-appropriate default.
815                let default_val = match arg.arg_type.as_str() {
816                    "string" => "\"\"".to_string(),
817                    "int" | "integer" => "0".to_string(),
818                    "float" | "number" => "0.0d".to_string(),
819                    "bool" | "boolean" => "false".to_string(),
820                    _ => "null".to_string(),
821                };
822                parts.push(default_val);
823            }
824            Some(v) => {
825                if arg.arg_type == "json_object" {
826                    // Array value: generate a typed List<T> based on element_type.
827                    if let Some(arr) = v.as_array() {
828                        parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
829                        continue;
830                    }
831                    // Object value with known type: deserialize via JsonSerializer so the
832                    // library's own [JsonPropertyName] annotations handle field name mapping.
833                    if let Some(opts_type) = options_type {
834                        if v.is_object() {
835                            let json_str = serde_json::to_string(v).unwrap_or_default();
836                            parts.push(format!(
837                                "JsonSerializer.Deserialize<{opts_type}>(\"{}\", ConfigOptions)!",
838                                escape_csharp(&json_str),
839                            ));
840                            continue;
841                        }
842                    }
843                }
844                parts.push(json_to_csharp(v));
845            }
846        }
847    }
848
849    (setup_lines, parts.join(", "))
850}
851
852/// Convert a JSON array to a typed C# `List<T>` expression.
853///
854/// Mapping from `ArgMapping::element_type`:
855/// - `None` or any string type → `List<string>`
856/// - `"f32"` → `List<float>` with `(float)` casts
857/// - `"(String, String)"` → `List<List<string>>` for key-value pair arrays
858fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
859    match element_type {
860        Some("f32") => {
861            let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
862            format!("new List<float>() {{ {} }}", items.join(", "))
863        }
864        Some("(String, String)") => {
865            let items: Vec<String> = arr
866                .iter()
867                .map(|v| {
868                    let strs: Vec<String> = v
869                        .as_array()
870                        .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
871                    format!("new List<string>() {{ {} }}", strs.join(", "))
872                })
873                .collect();
874            format!("new List<List<string>>() {{ {} }}", items.join(", "))
875        }
876        _ => {
877            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
878            format!("new List<string>() {{ {} }}", items.join(", "))
879        }
880    }
881}
882
883#[allow(clippy::too_many_arguments)]
884fn render_assertion(
885    out: &mut String,
886    assertion: &Assertion,
887    result_var: &str,
888    class_name: &str,
889    exception_class: &str,
890    field_resolver: &FieldResolver,
891    result_is_simple: bool,
892    result_is_vec: bool,
893) {
894    // Handle synthetic / derived fields before the is_valid_for_result check
895    // so they are never treated as struct property accesses on the result.
896    if let Some(f) = &assertion.field {
897        match f.as_str() {
898            "chunks_have_content" => {
899                let pred = format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
900                match assertion.assertion_type.as_str() {
901                    "is_true" => {
902                        let _ = writeln!(out, "        Assert.True({pred});");
903                    }
904                    "is_false" => {
905                        let _ = writeln!(out, "        Assert.False({pred});");
906                    }
907                    _ => {
908                        let _ = writeln!(
909                            out,
910                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
911                        );
912                    }
913                }
914                return;
915            }
916            "chunks_have_embeddings" => {
917                let pred =
918                    format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
919                match assertion.assertion_type.as_str() {
920                    "is_true" => {
921                        let _ = writeln!(out, "        Assert.True({pred});");
922                    }
923                    "is_false" => {
924                        let _ = writeln!(out, "        Assert.False({pred});");
925                    }
926                    _ => {
927                        let _ = writeln!(
928                            out,
929                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
930                        );
931                    }
932                }
933                return;
934            }
935            // ---- EmbedResponse virtual fields ----
936            // embed_texts returns List<List<float>> in C# — no wrapper object.
937            // result_var is the embedding matrix; use it directly.
938            "embeddings" => {
939                match assertion.assertion_type.as_str() {
940                    "count_equals" => {
941                        if let Some(val) = &assertion.value {
942                            let cs_val = json_to_csharp(val);
943                            let _ = writeln!(out, "        Assert.True({result_var}.Count == {cs_val});");
944                        }
945                    }
946                    "count_min" => {
947                        if let Some(val) = &assertion.value {
948                            let cs_val = json_to_csharp(val);
949                            let _ = writeln!(out, "        Assert.True({result_var}.Count >= {cs_val});");
950                        }
951                    }
952                    "not_empty" => {
953                        let _ = writeln!(out, "        Assert.NotEmpty({result_var});");
954                    }
955                    "is_empty" => {
956                        let _ = writeln!(out, "        Assert.Empty({result_var});");
957                    }
958                    _ => {
959                        let _ = writeln!(
960                            out,
961                            "        // skipped: unsupported assertion type on synthetic field 'embeddings'"
962                        );
963                    }
964                }
965                return;
966            }
967            "embedding_dimensions" => {
968                let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
969                match assertion.assertion_type.as_str() {
970                    "equals" => {
971                        if let Some(val) = &assertion.value {
972                            let cs_val = json_to_csharp(val);
973                            let _ = writeln!(out, "        Assert.True({expr} == {cs_val});");
974                        }
975                    }
976                    "greater_than" => {
977                        if let Some(val) = &assertion.value {
978                            let cs_val = json_to_csharp(val);
979                            let _ = writeln!(out, "        Assert.True({expr} > {cs_val});");
980                        }
981                    }
982                    _ => {
983                        let _ = writeln!(
984                            out,
985                            "        // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
986                        );
987                    }
988                }
989                return;
990            }
991            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
992                let pred = match f.as_str() {
993                    "embeddings_valid" => {
994                        format!("{result_var}.All(e => e.Count > 0)")
995                    }
996                    "embeddings_finite" => {
997                        format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
998                    }
999                    "embeddings_non_zero" => {
1000                        format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
1001                    }
1002                    "embeddings_normalized" => {
1003                        format!(
1004                            "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
1005                        )
1006                    }
1007                    _ => unreachable!(),
1008                };
1009                match assertion.assertion_type.as_str() {
1010                    "is_true" => {
1011                        let _ = writeln!(out, "        Assert.True({pred});");
1012                    }
1013                    "is_false" => {
1014                        let _ = writeln!(out, "        Assert.False({pred});");
1015                    }
1016                    _ => {
1017                        let _ = writeln!(
1018                            out,
1019                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
1020                        );
1021                    }
1022                }
1023                return;
1024            }
1025            // ---- keywords / keywords_count ----
1026            // C# ExtractionResult does not expose extracted_keywords; skip.
1027            "keywords" | "keywords_count" => {
1028                let _ = writeln!(
1029                    out,
1030                    "        // skipped: field '{f}' not available on C# ExtractionResult"
1031                );
1032                return;
1033            }
1034            _ => {}
1035        }
1036    }
1037
1038    // Skip assertions on fields that don't exist on the result type.
1039    if let Some(f) = &assertion.field {
1040        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1041            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
1042            return;
1043        }
1044    }
1045
1046    // When the result is a List<T>, index into the first element for field access.
1047    let effective_result_var: String = if result_is_vec {
1048        format!("{result_var}[0]")
1049    } else {
1050        result_var.to_string()
1051    };
1052
1053    let field_expr = if result_is_simple {
1054        effective_result_var.clone()
1055    } else {
1056        match &assertion.field {
1057            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
1058            _ => effective_result_var.clone(),
1059        }
1060    };
1061
1062    match assertion.assertion_type.as_str() {
1063        "equals" => {
1064            if let Some(expected) = &assertion.value {
1065                let cs_val = json_to_csharp(expected);
1066                if expected.is_string() {
1067                    // Only call .Trim() on string fields.
1068                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr}.Trim());");
1069                } else if expected.as_bool() == Some(true) {
1070                    // Boolean true: use Assert.True to avoid xUnit2004 warning.
1071                    let _ = writeln!(out, "        Assert.True({field_expr});");
1072                } else if expected.as_bool() == Some(false) {
1073                    // Boolean false: use Assert.False to avoid xUnit2004 warning.
1074                    let _ = writeln!(out, "        Assert.False({field_expr});");
1075                } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
1076                    // Integer values: use Assert.True(x == n) to avoid xUnit overload
1077                    // resolution ambiguity (int vs uint vs long vs DateTime).
1078                    let _ = writeln!(out, "        Assert.True({field_expr} == {cs_val});");
1079                } else {
1080                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr});");
1081                }
1082            }
1083        }
1084        "contains" => {
1085            if let Some(expected) = &assertion.value {
1086                // Lowercase both expected and actual so that enum fields (where .ToString()
1087                // returns the PascalCase C# member name like "Anchor") correctly match
1088                // fixture snake_case values like "anchor".  String fields are unaffected
1089                // because lowercasing both sides preserves substring matches.
1090                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
1091                let cs_val = lower_expected
1092                    .as_deref()
1093                    .map(|s| format!("\"{}\"", escape_csharp(s)))
1094                    .unwrap_or_else(|| json_to_csharp(expected));
1095                let _ = writeln!(
1096                    out,
1097                    "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
1098                );
1099            }
1100        }
1101        "contains_all" => {
1102            if let Some(values) = &assertion.values {
1103                for val in values {
1104                    let lower_val = val.as_str().map(|s| s.to_lowercase());
1105                    let cs_val = lower_val
1106                        .as_deref()
1107                        .map(|s| format!("\"{}\"", escape_csharp(s)))
1108                        .unwrap_or_else(|| json_to_csharp(val));
1109                    let _ = writeln!(
1110                        out,
1111                        "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
1112                    );
1113                }
1114            }
1115        }
1116        "not_contains" => {
1117            if let Some(expected) = &assertion.value {
1118                let cs_val = json_to_csharp(expected);
1119                let _ = writeln!(out, "        Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
1120            }
1121        }
1122        "not_empty" => {
1123            let _ = writeln!(
1124                out,
1125                "        Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
1126            );
1127        }
1128        "is_empty" => {
1129            let _ = writeln!(
1130                out,
1131                "        Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
1132            );
1133        }
1134        "contains_any" => {
1135            if let Some(values) = &assertion.values {
1136                let checks: Vec<String> = values
1137                    .iter()
1138                    .map(|v| {
1139                        let cs_val = json_to_csharp(v);
1140                        format!("{field_expr}.ToString().Contains({cs_val})")
1141                    })
1142                    .collect();
1143                let joined = checks.join(" || ");
1144                let _ = writeln!(
1145                    out,
1146                    "        Assert.True({joined}, \"expected to contain at least one of the specified values\");"
1147                );
1148            }
1149        }
1150        "greater_than" => {
1151            if let Some(val) = &assertion.value {
1152                let cs_val = json_to_csharp(val);
1153                let _ = writeln!(
1154                    out,
1155                    "        Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
1156                );
1157            }
1158        }
1159        "less_than" => {
1160            if let Some(val) = &assertion.value {
1161                let cs_val = json_to_csharp(val);
1162                let _ = writeln!(
1163                    out,
1164                    "        Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
1165                );
1166            }
1167        }
1168        "greater_than_or_equal" => {
1169            if let Some(val) = &assertion.value {
1170                let cs_val = json_to_csharp(val);
1171                let _ = writeln!(
1172                    out,
1173                    "        Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1174                );
1175            }
1176        }
1177        "less_than_or_equal" => {
1178            if let Some(val) = &assertion.value {
1179                let cs_val = json_to_csharp(val);
1180                let _ = writeln!(
1181                    out,
1182                    "        Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
1183                );
1184            }
1185        }
1186        "starts_with" => {
1187            if let Some(expected) = &assertion.value {
1188                let cs_val = json_to_csharp(expected);
1189                let _ = writeln!(out, "        Assert.StartsWith({cs_val}, {field_expr});");
1190            }
1191        }
1192        "ends_with" => {
1193            if let Some(expected) = &assertion.value {
1194                let cs_val = json_to_csharp(expected);
1195                let _ = writeln!(out, "        Assert.EndsWith({cs_val}, {field_expr});");
1196            }
1197        }
1198        "min_length" => {
1199            if let Some(val) = &assertion.value {
1200                if let Some(n) = val.as_u64() {
1201                    let _ = writeln!(
1202                        out,
1203                        "        Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
1204                    );
1205                }
1206            }
1207        }
1208        "max_length" => {
1209            if let Some(val) = &assertion.value {
1210                if let Some(n) = val.as_u64() {
1211                    let _ = writeln!(
1212                        out,
1213                        "        Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
1214                    );
1215                }
1216            }
1217        }
1218        "count_min" => {
1219            if let Some(val) = &assertion.value {
1220                if let Some(n) = val.as_u64() {
1221                    let _ = writeln!(
1222                        out,
1223                        "        Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
1224                    );
1225                }
1226            }
1227        }
1228        "count_equals" => {
1229            if let Some(val) = &assertion.value {
1230                if let Some(n) = val.as_u64() {
1231                    let _ = writeln!(out, "        Assert.Equal({n}, {field_expr}.Count);");
1232                }
1233            }
1234        }
1235        "is_true" => {
1236            let _ = writeln!(out, "        Assert.True({field_expr});");
1237        }
1238        "is_false" => {
1239            let _ = writeln!(out, "        Assert.False({field_expr});");
1240        }
1241        "not_error" => {
1242            // Already handled by the call succeeding without exception.
1243        }
1244        "error" => {
1245            // Handled at the test method level.
1246        }
1247        "method_result" => {
1248            if let Some(method_name) = &assertion.method {
1249                let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
1250                let check = assertion.check.as_deref().unwrap_or("is_true");
1251                match check {
1252                    "equals" => {
1253                        if let Some(val) = &assertion.value {
1254                            if val.as_bool() == Some(true) {
1255                                let _ = writeln!(out, "        Assert.True({call_expr});");
1256                            } else if val.as_bool() == Some(false) {
1257                                let _ = writeln!(out, "        Assert.False({call_expr});");
1258                            } else {
1259                                let cs_val = json_to_csharp(val);
1260                                let _ = writeln!(out, "        Assert.Equal({cs_val}, {call_expr});");
1261                            }
1262                        }
1263                    }
1264                    "is_true" => {
1265                        let _ = writeln!(out, "        Assert.True({call_expr});");
1266                    }
1267                    "is_false" => {
1268                        let _ = writeln!(out, "        Assert.False({call_expr});");
1269                    }
1270                    "greater_than_or_equal" => {
1271                        if let Some(val) = &assertion.value {
1272                            let n = val.as_u64().unwrap_or(0);
1273                            let _ = writeln!(out, "        Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
1274                        }
1275                    }
1276                    "count_min" => {
1277                        if let Some(val) = &assertion.value {
1278                            let n = val.as_u64().unwrap_or(0);
1279                            let _ = writeln!(
1280                                out,
1281                                "        Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
1282                            );
1283                        }
1284                    }
1285                    "is_error" => {
1286                        let _ = writeln!(
1287                            out,
1288                            "        Assert.Throws<{exception_class}>(() => {{ {call_expr}; }});"
1289                        );
1290                    }
1291                    "contains" => {
1292                        if let Some(val) = &assertion.value {
1293                            let cs_val = json_to_csharp(val);
1294                            let _ = writeln!(out, "        Assert.Contains({cs_val}, {call_expr});");
1295                        }
1296                    }
1297                    other_check => {
1298                        panic!("C# e2e generator: unsupported method_result check type: {other_check}");
1299                    }
1300                }
1301            } else {
1302                panic!("C# e2e generator: method_result assertion missing 'method' field");
1303            }
1304        }
1305        "matches_regex" => {
1306            if let Some(expected) = &assertion.value {
1307                let cs_val = json_to_csharp(expected);
1308                let _ = writeln!(out, "        Assert.Matches({cs_val}, {field_expr});");
1309            }
1310        }
1311        other => {
1312            panic!("C# e2e generator: unsupported assertion type: {other}");
1313        }
1314    }
1315}
1316
1317/// Recursively sort JSON objects so that any key named `"type"` appears first.
1318///
1319/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
1320/// the first property when deserializing polymorphic types. Fixture config values
1321/// serialised via serde_json preserve insertion/alphabetical order, which may put
1322/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
1323fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
1324    match value {
1325        serde_json::Value::Object(map) => {
1326            let mut sorted = serde_json::Map::with_capacity(map.len());
1327            // Insert "type" first if present.
1328            if let Some(type_val) = map.get("type") {
1329                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
1330            }
1331            for (k, v) in map {
1332                if k != "type" {
1333                    sorted.insert(k, sort_discriminator_first(v));
1334                }
1335            }
1336            serde_json::Value::Object(sorted)
1337        }
1338        serde_json::Value::Array(arr) => {
1339            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
1340        }
1341        other => other,
1342    }
1343}
1344
1345/// Convert a `serde_json::Value` to a C# literal string.
1346fn json_to_csharp(value: &serde_json::Value) -> String {
1347    match value {
1348        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
1349        serde_json::Value::Bool(true) => "true".to_string(),
1350        serde_json::Value::Bool(false) => "false".to_string(),
1351        serde_json::Value::Number(n) => {
1352            if n.is_f64() {
1353                format!("{}d", n)
1354            } else {
1355                n.to_string()
1356            }
1357        }
1358        serde_json::Value::Null => "null".to_string(),
1359        serde_json::Value::Array(arr) => {
1360            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1361            format!("new[] {{ {} }}", items.join(", "))
1362        }
1363        serde_json::Value::Object(_) => {
1364            let json_str = serde_json::to_string(value).unwrap_or_default();
1365            format!("\"{}\"", escape_csharp(&json_str))
1366        }
1367    }
1368}
1369
1370// ---------------------------------------------------------------------------
1371// Visitor generation
1372// ---------------------------------------------------------------------------
1373
1374/// Build a C# visitor: add an instantiation line to `setup_lines` and push
1375/// a private nested class declaration to `class_decls` (emitted at class scope,
1376/// outside any method body — C# does not allow local class declarations inside
1377/// methods).  Each fixture gets a unique class name derived from its ID to avoid
1378/// duplicate-name compile errors when multiple visitor fixtures exist per file.
1379/// Returns the visitor variable name for use as a call argument.
1380fn build_csharp_visitor(
1381    setup_lines: &mut Vec<String>,
1382    class_decls: &mut Vec<String>,
1383    fixture_id: &str,
1384    visitor_spec: &crate::fixture::VisitorSpec,
1385) -> String {
1386    use heck::ToUpperCamelCase;
1387    let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
1388    let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
1389
1390    setup_lines.push(format!("var {var_name} = new {class_name}();"));
1391
1392    // Build the class declaration string (indented for nesting inside the test class).
1393    let mut decl = String::new();
1394    let _ = writeln!(decl, "    private sealed class {class_name} : IVisitor");
1395    let _ = writeln!(decl, "    {{");
1396    for (method_name, action) in &visitor_spec.callbacks {
1397        emit_csharp_visitor_method(&mut decl, method_name, action);
1398    }
1399    let _ = writeln!(decl, "    }}");
1400    class_decls.push(decl);
1401
1402    var_name
1403}
1404
1405/// Emit a C# visitor method into a class declaration string.
1406fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
1407    let camel_method = method_to_camel(method_name);
1408    let params = match method_name {
1409        "visit_link" => "VisitContext ctx, string href, string text, string title",
1410        "visit_image" => "VisitContext ctx, string src, string alt, string title",
1411        "visit_heading" => "VisitContext ctx, int level, string text, string id",
1412        "visit_code_block" => "VisitContext ctx, string lang, string code",
1413        "visit_code_inline"
1414        | "visit_strong"
1415        | "visit_emphasis"
1416        | "visit_strikethrough"
1417        | "visit_underline"
1418        | "visit_subscript"
1419        | "visit_superscript"
1420        | "visit_mark"
1421        | "visit_button"
1422        | "visit_summary"
1423        | "visit_figcaption"
1424        | "visit_definition_term"
1425        | "visit_definition_description" => "VisitContext ctx, string text",
1426        "visit_text" => "VisitContext ctx, string text",
1427        "visit_list_item" => "VisitContext ctx, bool ordered, string marker, string text",
1428        "visit_blockquote" => "VisitContext ctx, string content, int depth",
1429        "visit_table_row" => "VisitContext ctx, IReadOnlyList<string> cells, bool isHeader",
1430        "visit_custom_element" => "VisitContext ctx, string tagName, string html",
1431        "visit_form" => "VisitContext ctx, string actionUrl, string method",
1432        "visit_input" => "VisitContext ctx, string inputType, string name, string value",
1433        "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, string src",
1434        "visit_details" => "VisitContext ctx, bool isOpen",
1435        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1436            "VisitContext ctx, string output"
1437        }
1438        "visit_list_start" => "VisitContext ctx, bool ordered",
1439        "visit_list_end" => "VisitContext ctx, bool ordered, string output",
1440        _ => "VisitContext ctx",
1441    };
1442
1443    let _ = writeln!(decl, "        public VisitResult {camel_method}({params})");
1444    let _ = writeln!(decl, "        {{");
1445    match action {
1446        CallbackAction::Skip => {
1447            let _ = writeln!(decl, "            return VisitResult.Skip();");
1448        }
1449        CallbackAction::Continue => {
1450            let _ = writeln!(decl, "            return VisitResult.Continue();");
1451        }
1452        CallbackAction::PreserveHtml => {
1453            let _ = writeln!(decl, "            return VisitResult.PreserveHtml();");
1454        }
1455        CallbackAction::Custom { output } => {
1456            let escaped = escape_csharp(output);
1457            let _ = writeln!(decl, "            return VisitResult.Custom(\"{escaped}\");");
1458        }
1459        CallbackAction::CustomTemplate { template } => {
1460            let escaped = escape_csharp(template);
1461            let _ = writeln!(decl, "            return VisitResult.Custom($\"{escaped}\");");
1462        }
1463    }
1464    let _ = writeln!(decl, "        }}");
1465}
1466
1467/// Convert snake_case method names to C# PascalCase.
1468fn method_to_camel(snake: &str) -> String {
1469    use heck::ToUpperCamelCase;
1470    snake.to_upper_camel_case()
1471}
1472
1473/// Build a C# call expression for a `method_result` assertion on a tree-sitter Tree.
1474///
1475/// Maps well-known method names to the appropriate C# static helper calls on the
1476/// generated lib class, falling back to `result_var.PascalCase()` for unknowns.
1477fn build_csharp_method_call(
1478    result_var: &str,
1479    method_name: &str,
1480    args: Option<&serde_json::Value>,
1481    class_name: &str,
1482) -> String {
1483    match method_name {
1484        "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
1485        "root_node_type" => format!("{result_var}.RootNode.Kind"),
1486        "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
1487        "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
1488        "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
1489        "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
1490        "contains_node_type" => {
1491            let node_type = args
1492                .and_then(|a| a.get("node_type"))
1493                .and_then(|v| v.as_str())
1494                .unwrap_or("");
1495            format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
1496        }
1497        "find_nodes_by_type" => {
1498            let node_type = args
1499                .and_then(|a| a.get("node_type"))
1500                .and_then(|v| v.as_str())
1501                .unwrap_or("");
1502            format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
1503        }
1504        "run_query" => {
1505            let query_source = args
1506                .and_then(|a| a.get("query_source"))
1507                .and_then(|v| v.as_str())
1508                .unwrap_or("");
1509            let language = args
1510                .and_then(|a| a.get("language"))
1511                .and_then(|v| v.as_str())
1512                .unwrap_or("");
1513            format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1514        }
1515        _ => {
1516            use heck::ToUpperCamelCase;
1517            let pascal = method_name.to_upper_camel_case();
1518            format!("{result_var}.{pascal}()")
1519        }
1520    }
1521}