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::ResolvedCrateConfig;
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::hash::{Hash, Hasher};
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22use super::client;
23
24/// C# e2e code generator.
25pub struct CSharpCodegen;
26
27impl E2eCodegen for CSharpCodegen {
28    fn generate(
29        &self,
30        groups: &[FixtureGroup],
31        e2e_config: &E2eConfig,
32        config: &ResolvedCrateConfig,
33    ) -> Result<Vec<GeneratedFile>> {
34        let lang = self.language_name();
35        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
36
37        let mut files = Vec::new();
38
39        // Resolve call config with overrides.
40        let call = &e2e_config.call;
41        let overrides = call.overrides.get(lang);
42        let function_name = overrides
43            .and_then(|o| o.function.as_ref())
44            .cloned()
45            .unwrap_or_else(|| call.function.to_upper_camel_case());
46        let class_name = overrides
47            .and_then(|o| o.class.as_ref())
48            .cloned()
49            .unwrap_or_else(|| format!("{}Lib", config.name.to_upper_camel_case()));
50        // The exception class is always {CrateName}Exception, generated by the C# backend.
51        let exception_class = format!("{}Exception", config.name.to_upper_camel_case());
52        let namespace = overrides
53            .and_then(|o| o.module.as_ref())
54            .cloned()
55            .or_else(|| config.csharp.as_ref().and_then(|cs| cs.namespace.clone()))
56            .unwrap_or_else(|| {
57                if call.module.is_empty() {
58                    "Kreuzberg".to_string()
59                } else {
60                    call.module.to_upper_camel_case()
61                }
62            });
63        let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
64        let result_var = &call.result_var;
65        let is_async = call.r#async;
66
67        // Resolve package config.
68        let cs_pkg = e2e_config.resolve_package("csharp");
69        let pkg_name = cs_pkg
70            .as_ref()
71            .and_then(|p| p.name.as_ref())
72            .cloned()
73            .unwrap_or_else(|| config.name.to_upper_camel_case());
74        // Alef scaffolds C# packages as packages/csharp/<Namespace>/<Namespace>.csproj.
75        let pkg_path = cs_pkg
76            .as_ref()
77            .and_then(|p| p.path.as_ref())
78            .cloned()
79            .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}/{pkg_name}.csproj"));
80        let pkg_version = cs_pkg
81            .as_ref()
82            .and_then(|p| p.version.as_ref())
83            .cloned()
84            .or_else(|| config.resolved_version())
85            .unwrap_or_else(|| "0.1.0".to_string());
86
87        // Generate the .csproj using a unique name derived from the package name so
88        // it does not conflict with any hand-written project files in the same directory.
89        let csproj_name = format!("{pkg_name}.E2eTests.csproj");
90        files.push(GeneratedFile {
91            path: output_base.join(&csproj_name),
92            content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
93            generated_header: false,
94        });
95
96        // Emit a TestSetup.cs whose ModuleInitializer chdirs to test_documents
97        // so fixture-relative paths like "docx/fake.docx" resolve correctly when
98        // dotnet test runs from bin/{Configuration}/{Tfm}.
99        files.push(GeneratedFile {
100            path: output_base.join("TestSetup.cs"),
101            content: render_test_setup(),
102            generated_header: true,
103        });
104
105        // Generate test files per category.
106        let tests_base = output_base.join("tests");
107        let field_resolver = FieldResolver::new(
108            &e2e_config.fields,
109            &e2e_config.fields_optional,
110            &e2e_config.result_fields,
111            &e2e_config.fields_array,
112            &std::collections::HashSet::new(),
113        );
114
115        // Resolve enum_fields and nested_types from C# override config.
116        static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
117        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
118
119        // Build effective nested_types by merging defaults with configured overrides.
120        let mut effective_nested_types = default_csharp_nested_types();
121        if let Some(overrides_map) = overrides.map(|o| &o.nested_types) {
122            effective_nested_types.extend(overrides_map.clone());
123        }
124
125        for group in groups {
126            let active: Vec<&Fixture> = group
127                .fixtures
128                .iter()
129                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
130                .collect();
131
132            if active.is_empty() {
133                continue;
134            }
135
136            let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
137            let filename = format!("{test_class}.cs");
138            let content = render_test_file(
139                &group.category,
140                &active,
141                &namespace,
142                &class_name,
143                &function_name,
144                &exception_class,
145                result_var,
146                &test_class,
147                &e2e_config.call.args,
148                &field_resolver,
149                result_is_simple,
150                is_async,
151                e2e_config,
152                enum_fields,
153                &effective_nested_types,
154            );
155            files.push(GeneratedFile {
156                path: tests_base.join(filename),
157                content,
158                generated_header: true,
159            });
160        }
161
162        Ok(files)
163    }
164
165    fn language_name(&self) -> &'static str {
166        "csharp"
167    }
168}
169
170// ---------------------------------------------------------------------------
171// Rendering
172// ---------------------------------------------------------------------------
173
174fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
175    let pkg_ref = match dep_mode {
176        crate::config::DependencyMode::Registry => {
177            format!("    <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
178        }
179        crate::config::DependencyMode::Local => {
180            format!("    <ProjectReference Include=\"{pkg_path}\" />")
181        }
182    };
183    crate::template_env::render(
184        "csharp/csproj.jinja",
185        minijinja::context! {
186            pkg_ref => pkg_ref,
187            microsoft_net_test_sdk_version => tv::nuget::MICROSOFT_NET_TEST_SDK,
188            xunit_version => tv::nuget::XUNIT,
189            xunit_runner_version => tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
190        },
191    )
192}
193
194fn render_test_setup() -> String {
195    let mut out = String::new();
196    out.push_str(&hash::header(CommentStyle::DoubleSlash));
197    out.push_str(
198        r#"using System;
199using System.IO;
200using System.Runtime.CompilerServices;
201
202namespace Kreuzberg.E2eTests;
203
204internal static class TestSetup
205{
206    [ModuleInitializer]
207    internal static void Init()
208    {
209        // Walk up from the assembly directory until we find the repo root
210        // (the directory containing test_documents/) so that fixture paths
211        // like "docx/fake.docx" resolve regardless of where dotnet test
212        // launched the runner from.
213        var dir = new DirectoryInfo(AppContext.BaseDirectory);
214        while (dir != null)
215        {
216            var candidate = Path.Combine(dir.FullName, "test_documents");
217            if (Directory.Exists(candidate))
218            {
219                Directory.SetCurrentDirectory(candidate);
220                return;
221            }
222            dir = dir.Parent;
223        }
224    }
225}
226"#,
227    );
228    out
229}
230
231#[allow(clippy::too_many_arguments)]
232fn render_test_file(
233    category: &str,
234    fixtures: &[&Fixture],
235    namespace: &str,
236    class_name: &str,
237    function_name: &str,
238    exception_class: &str,
239    result_var: &str,
240    test_class: &str,
241    args: &[crate::config::ArgMapping],
242    field_resolver: &FieldResolver,
243    result_is_simple: bool,
244    is_async: bool,
245    e2e_config: &E2eConfig,
246    enum_fields: &HashMap<String, String>,
247    nested_types: &HashMap<String, String>,
248) -> String {
249    // Collect using imports
250    let mut using_imports = String::new();
251    using_imports.push_str("using System;\n");
252    using_imports.push_str("using System.Collections.Generic;\n");
253    using_imports.push_str("using System.Linq;\n");
254    using_imports.push_str("using System.Net.Http;\n");
255    using_imports.push_str("using System.Text;\n");
256    using_imports.push_str("using System.Text.Json;\n");
257    using_imports.push_str("using System.Text.Json.Serialization;\n");
258    using_imports.push_str("using System.Threading.Tasks;\n");
259    using_imports.push_str("using Xunit;\n");
260    using_imports.push_str(&format!("using {namespace};\n"));
261    using_imports.push_str(&format!("using static {namespace}.{class_name};\n"));
262
263    // Shared options field
264    let config_options_field = "    private static readonly JsonSerializerOptions ConfigOptions = new() { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault };";
265
266    // Visitor class declarations accumulated across all fixtures — emitted as
267    // private nested classes inside the test class but outside any method body.
268    // C# does not allow local class declarations inside method bodies.
269    let mut visitor_class_decls: Vec<String> = Vec::new();
270
271    // Build fixtures body
272    let mut fixtures_body = String::new();
273    for (i, fixture) in fixtures.iter().enumerate() {
274        render_test_method(
275            &mut fixtures_body,
276            &mut visitor_class_decls,
277            fixture,
278            class_name,
279            function_name,
280            exception_class,
281            result_var,
282            args,
283            field_resolver,
284            result_is_simple,
285            is_async,
286            e2e_config,
287            enum_fields,
288            nested_types,
289        );
290        if i + 1 < fixtures.len() {
291            fixtures_body.push('\n');
292        }
293    }
294
295    // Build visitor classes string
296    let mut visitor_classes_str = String::new();
297    for (i, decl) in visitor_class_decls.iter().enumerate() {
298        if i > 0 {
299            visitor_classes_str.push('\n');
300        }
301        visitor_classes_str.push('\n');
302        // Indent each line by 4 spaces to nest inside the test class
303        for line in decl.lines() {
304            visitor_classes_str.push_str("    ");
305            visitor_classes_str.push_str(line);
306            visitor_classes_str.push('\n');
307        }
308    }
309
310    let ctx = minijinja::context! {
311        header => hash::header(CommentStyle::DoubleSlash),
312        using_imports => using_imports,
313        category => category,
314        namespace => namespace,
315        test_class => test_class,
316        config_options_field => config_options_field,
317        fixtures_body => fixtures_body,
318        visitor_class_decls => visitor_classes_str,
319    };
320
321    crate::template_env::render("csharp/test_file.jinja", ctx)
322}
323
324// ---------------------------------------------------------------------------
325// HTTP test rendering — shared-driver integration
326// ---------------------------------------------------------------------------
327
328/// Renderer that emits xUnit `[Fact] public async Task Test_*()` methods using
329/// `System.Net.Http.HttpClient` against the mock server at `MOCK_SERVER_URL`.
330/// Satisfies [`client::TestClientRenderer`] so the shared
331/// [`client::http_call::render_http_test`] driver drives the call sequence.
332struct CSharpTestClientRenderer;
333
334/// C# HttpMethod static properties are PascalCase (Get, Post, Put, Delete, …).
335fn to_csharp_http_method(method: &str) -> String {
336    let lower = method.to_ascii_lowercase();
337    let mut chars = lower.chars();
338    match chars.next() {
339        Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
340        None => String::new(),
341    }
342}
343
344/// Headers that belong to `request.Content.Headers` rather than `request.Headers`.
345///
346/// Adding these to `request.Headers` causes .NET to throw "Misused header name".
347const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
348    "content-length",
349    "host",
350    "connection",
351    "expect",
352    "transfer-encoding",
353    "upgrade",
354    // Content-Type is owned by request.Content.Headers and is set when
355    // StringContent is constructed; adding it to request.Headers throws.
356    "content-type",
357    // Other entity headers also belong to request.Content.Headers.
358    "content-encoding",
359    "content-language",
360    "content-location",
361    "content-md5",
362    "content-range",
363    "content-disposition",
364];
365
366/// Whether `name` (any case) belongs to `response.Content.Headers` rather than
367/// `response.Headers`. Picking the wrong collection causes .NET to throw
368/// "Misused header name".
369fn is_csharp_content_header(name: &str) -> bool {
370    matches!(
371        name.to_ascii_lowercase().as_str(),
372        "content-type"
373            | "content-length"
374            | "content-encoding"
375            | "content-language"
376            | "content-location"
377            | "content-md5"
378            | "content-range"
379            | "content-disposition"
380            | "expires"
381            | "last-modified"
382            | "allow"
383    )
384}
385
386impl client::TestClientRenderer for CSharpTestClientRenderer {
387    fn language_name(&self) -> &'static str {
388        "csharp"
389    }
390
391    /// Convert a fixture id to the PascalCase identifier used in `Test_{name}`.
392    fn sanitize_test_name(&self, id: &str) -> String {
393        id.to_upper_camel_case()
394    }
395
396    /// Emit `[Fact]` (or `[Fact(Skip = "…")]` for skipped tests), the method
397    /// signature, the opening brace, and the description comment.
398    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
399        let escaped_reason = skip_reason.map(escape_csharp);
400        let rendered = crate::template_env::render(
401            "csharp/http_test_open.jinja",
402            minijinja::context! {
403                fn_name => fn_name,
404                description => description,
405                skip_reason => escaped_reason,
406            },
407        );
408        out.push_str(&rendered);
409    }
410
411    /// Emit the closing `}` for a test method.
412    fn render_test_close(&self, out: &mut String) {
413        let rendered = crate::template_env::render("csharp/http_test_close.jinja", minijinja::context! {});
414        out.push_str(&rendered);
415    }
416
417    /// Emit the `HttpRequestMessage` construction, headers, cookies, body, and
418    /// `var response = await client.SendAsync(request)`.
419    ///
420    /// The fixture path follows the mock-server convention `/fixtures/<id>`.
421    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
422        let method = to_csharp_http_method(ctx.method);
423        let path = escape_csharp(ctx.path);
424
425        out.push_str("        var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";\n");
426        // Disable auto-follow so redirect-status fixtures (3xx) can assert the
427        // server's status code rather than the followed-target's status.
428        out.push_str(
429            "        using var handler = new System.Net.Http.HttpClientHandler { AllowAutoRedirect = false };\n",
430        );
431        out.push_str("        using var client = new System.Net.Http.HttpClient(handler);\n");
432        out.push_str(&format!("        var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}{path}\");\n"));
433
434        // Set body + Content-Type when a request body is present.
435        if let Some(body) = ctx.body {
436            let content_type = ctx.content_type.unwrap_or("application/json");
437            let json_str = serde_json::to_string(body).unwrap_or_default();
438            let escaped = escape_csharp(&json_str);
439            out.push_str(&format!("        request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");\n"));
440        }
441
442        // Add request headers (skip restricted headers that belong to Content.Headers).
443        for (name, value) in ctx.headers {
444            if CSHARP_RESTRICTED_REQUEST_HEADERS.contains(&name.to_lowercase().as_str()) {
445                continue;
446            }
447            let escaped_name = escape_csharp(name);
448            let escaped_value = escape_csharp(value);
449            out.push_str(&format!(
450                "        request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");\n"
451            ));
452        }
453
454        // Combine cookies into a single `Cookie` header.
455        if !ctx.cookies.is_empty() {
456            let mut pairs: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
457            pairs.sort();
458            let cookie_header = escape_csharp(&pairs.join("; "));
459            out.push_str(&format!(
460                "        request.Headers.Add(\"Cookie\", \"{cookie_header}\");\n"
461            ));
462        }
463
464        out.push_str("        var response = await client.SendAsync(request);\n");
465    }
466
467    /// Emit `Assert.Equal(status, (int)response.StatusCode)`.
468    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
469        out.push_str(&format!("        Assert.Equal({status}, (int)response.StatusCode);\n"));
470    }
471
472    /// Emit a response-header assertion.
473    ///
474    /// Handles special tokens: `<<present>>`, `<<absent>>`, `<<uuid>>`.
475    /// Picks `response.Content.Headers` vs `response.Headers` based on the header name.
476    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
477        let target = if is_csharp_content_header(name) {
478            "response.Content.Headers"
479        } else {
480            "response.Headers"
481        };
482        let escaped_name = escape_csharp(name);
483        match expected {
484            "<<present>>" => {
485                out.push_str(&format!("        Assert.True({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be present\");\n"));
486            }
487            "<<absent>>" => {
488                out.push_str(&format!("        Assert.False({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be absent\");\n"));
489            }
490            "<<uuid>>" => {
491                // UUID regex: 8-4-4-4-12 hex groups.
492                out.push_str(&format!("        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\");\n"));
493            }
494            literal => {
495                // Use a deterministic local-variable name derived from the header name so
496                // multiple header assertions in the same method body do not redeclare.
497                let var_name = format!("hdr{}", sanitize_ident(name));
498                let escaped_value = escape_csharp(literal);
499                out.push_str(&format!("        Assert.True({target}.TryGetValues(\"{escaped_name}\", out var {var_name}) && {var_name}.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");\n"));
500            }
501        }
502    }
503
504    /// Emit a JSON body equality assertion via `JsonDocument`.
505    ///
506    /// Plain-string bodies are compared with `Assert.Equal` after trimming.
507    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
508        match expected {
509            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
510                let json_str = serde_json::to_string(expected).unwrap_or_default();
511                let escaped = escape_csharp(&json_str);
512                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
513                out.push_str("        var body = JsonDocument.Parse(bodyText).RootElement;\n");
514                out.push_str(&format!(
515                    "        var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;\n"
516                ));
517                out.push_str("        Assert.Equal(expectedBody.GetRawText(), body.GetRawText());\n");
518            }
519            serde_json::Value::String(s) => {
520                let escaped = escape_csharp(s);
521                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
522                out.push_str(&format!("        Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
523            }
524            other => {
525                let escaped = escape_csharp(&other.to_string());
526                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
527                out.push_str(&format!("        Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
528            }
529        }
530    }
531
532    /// Emit per-field equality assertions for a partial body match.
533    ///
534    /// Uses a separate `partialBodyText` local so it does not collide with
535    /// `bodyText` if `render_assert_json_body` was also called.
536    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
537        if let Some(obj) = expected.as_object() {
538            out.push_str("        var partialBodyText = await response.Content.ReadAsStringAsync();\n");
539            out.push_str("        var partialBody = JsonDocument.Parse(partialBodyText).RootElement;\n");
540            for (key, val) in obj {
541                let escaped_key = escape_csharp(key);
542                let json_str = serde_json::to_string(val).unwrap_or_default();
543                let escaped_val = escape_csharp(&json_str);
544                let var_name = format!("expected{}", key.to_upper_camel_case());
545                out.push_str(&format!(
546                    "        var {var_name} = JsonDocument.Parse(\"{escaped_val}\").RootElement;\n"
547                ));
548                out.push_str(&format!("        Assert.True(partialBody.TryGetProperty(\"{escaped_key}\", out var _partialProp{var_name}) && _partialProp{var_name}.GetRawText() == {var_name}.GetRawText(), \"partial body field '{escaped_key}' mismatch\");\n"));
549            }
550        }
551    }
552
553    /// Emit validation-error assertions by checking each expected `msg` string
554    /// appears in the JSON-encoded body.
555    fn render_assert_validation_errors(
556        &self,
557        out: &mut String,
558        _response_var: &str,
559        errors: &[ValidationErrorExpectation],
560    ) {
561        out.push_str("        var validationBodyText = await response.Content.ReadAsStringAsync();\n");
562        for err in errors {
563            let escaped_msg = escape_csharp(&err.msg);
564            out.push_str(&format!(
565                "        Assert.Contains(\"{escaped_msg}\", validationBodyText);\n"
566            ));
567        }
568    }
569}
570
571/// Render an HTTP server test method using the shared [`client::http_call::render_http_test`]
572/// driver via [`CSharpTestClientRenderer`].
573fn render_http_test_method(out: &mut String, fixture: &Fixture, _http: &HttpFixture) {
574    client::http_call::render_http_test(out, &CSharpTestClientRenderer, fixture);
575}
576
577#[allow(clippy::too_many_arguments)]
578fn render_test_method(
579    out: &mut String,
580    visitor_class_decls: &mut Vec<String>,
581    fixture: &Fixture,
582    class_name: &str,
583    _function_name: &str,
584    exception_class: &str,
585    _result_var: &str,
586    _args: &[crate::config::ArgMapping],
587    field_resolver: &FieldResolver,
588    result_is_simple: bool,
589    _is_async: bool,
590    e2e_config: &E2eConfig,
591    enum_fields: &HashMap<String, String>,
592    nested_types: &HashMap<String, String>,
593) {
594    let method_name = fixture.id.to_upper_camel_case();
595    let description = &fixture.description;
596
597    // HTTP fixtures: generate real HTTP client tests using System.Net.Http.
598    if let Some(http) = &fixture.http {
599        render_http_test_method(out, fixture, http);
600        return;
601    }
602
603    // Non-HTTP fixtures with no mock_response: skip only if the C# binding
604    // does not have a callable for this function via [e2e.call.overrides.csharp].
605    if fixture.mock_response.is_none() && !fixture_has_csharp_callable(fixture, e2e_config) {
606        let skip_reason =
607            "non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function";
608        let ctx = minijinja::context! {
609            is_skipped => true,
610            skip_reason => skip_reason,
611            description => description,
612            method_name => method_name,
613        };
614        let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
615        out.push_str(&rendered);
616        return;
617    }
618
619    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
620
621    // Resolve call config per-fixture so named calls (e.g. "parse") use the
622    // correct function name, result variable, and async flag.
623    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
624    let lang = "csharp";
625    let cs_overrides = call_config.overrides.get(lang);
626
627    // Streaming branch: chat_stream returns IAsyncEnumerable<ChatCompletionChunk>,
628    // not Task<T>. Emit `await foreach` over the stream, building local
629    // aggregator vars (`chunks`, `streamContent`, `streamComplete`, ...) and
630    // asserting on those locals — never on response pseudo-fields.
631    let raw_function_name = cs_overrides
632        .and_then(|o| o.function.as_ref())
633        .cloned()
634        .unwrap_or_else(|| call_config.function.clone());
635    if raw_function_name == "chat_stream" {
636        render_chat_stream_test_method(
637            out,
638            fixture,
639            class_name,
640            call_config,
641            cs_overrides,
642            e2e_config,
643            enum_fields,
644            nested_types,
645            exception_class,
646        );
647        return;
648    }
649
650    let effective_function_name = cs_overrides
651        .and_then(|o| o.function.as_ref())
652        .cloned()
653        .unwrap_or_else(|| call_config.function.to_upper_camel_case());
654    let effective_result_var = &call_config.result_var;
655    let effective_is_async = call_config.r#async;
656    let function_name = effective_function_name.as_str();
657    let result_var = effective_result_var.as_str();
658    let is_async = effective_is_async;
659    let args = call_config.args.as_slice();
660
661    // Per-call overrides: result shape, void returns, extra trailing args.
662    // Pull `result_is_simple` from the per-call config first (call-level value
663    // wins, then per-language override, then the top-level call's value).
664    let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
665    // result_is_bytes: when set, the call returns a raw byte[] in C# (not a
666    // struct with named fields). Mirrors the C codegen flag added in 50e1d309.
667    // Treat byte-buffer returns like result_is_simple (no struct accessors) and
668    // emit byte-specific assertions for `not_empty`/`is_empty`.
669    let per_call_result_is_bytes = call_config.result_is_bytes || cs_overrides.is_some_and(|o| o.result_is_bytes);
670    let effective_result_is_simple = result_is_simple || per_call_result_is_simple || per_call_result_is_bytes;
671    let effective_result_is_bytes = per_call_result_is_bytes;
672    let returns_void = call_config.returns_void;
673    let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
674    // options_type: prefer per-call override, fall back to top-level csharp override.
675    let top_level_options_type = e2e_config
676        .call
677        .overrides
678        .get("csharp")
679        .and_then(|o| o.options_type.as_deref());
680    let effective_options_type = cs_overrides
681        .and_then(|o| o.options_type.as_deref())
682        .or(top_level_options_type);
683
684    // options_via: how to construct the options object. Supported values:
685    //   "kwargs" (default) — emit a C# object initializer (`new T { ... }`).
686    //   "from_json"        — emit `JsonSerializer.Deserialize<T>(json, ConfigOptions)!`,
687    //                        sidestepping per-field type ambiguity for fields like
688    //                        `JsonElement?` (untagged unions) or `List<NamedRecord>`
689    //                        (where the codegen would otherwise default to `List<string>`).
690    let top_level_options_via = e2e_config
691        .call
692        .overrides
693        .get("csharp")
694        .and_then(|o| o.options_via.as_deref());
695    let effective_options_via = cs_overrides
696        .and_then(|o| o.options_via.as_deref())
697        .or(top_level_options_via);
698
699    let (mut setup_lines, args_str) = build_args_and_setup(
700        &fixture.input,
701        args,
702        class_name,
703        effective_options_type,
704        effective_options_via,
705        enum_fields,
706        nested_types,
707        &fixture.id,
708    );
709
710    // Build visitor if present: instantiate in method body, declare class at file scope.
711    let mut visitor_arg = String::new();
712    let has_visitor = fixture.visitor.is_some();
713    if let Some(visitor_spec) = &fixture.visitor {
714        visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_class_decls, &fixture.id, visitor_spec);
715    }
716
717    // When a visitor is present, embed it in the options object instead of passing as a separate arg.
718    // args_str should contain the function arguments with null for missing options (e.g., "html, null").
719    // We need to replace that null with a ConversionOptions instance that has Visitor set.
720    let final_args = if has_visitor && !visitor_arg.is_empty() {
721        let opts_type = effective_options_type.unwrap_or("ConversionOptions");
722        if args_str.contains("JsonSerializer.Deserialize") {
723            // Deserialize form: extract the deserialized object and set Visitor on it
724            setup_lines.push(format!("var options = {args_str};"));
725            setup_lines.push(format!("options.Visitor = {visitor_arg};"));
726            "options".to_string()
727        } else if args_str.ends_with(", null") {
728            // Replace trailing ", null" with options
729            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
730            let trimmed = args_str[..args_str.len() - 6].to_string(); // Remove ", null" (6 chars including space)
731            format!("{trimmed}, options")
732        } else if args_str.contains(", null,") {
733            // Options parameter is null in the middle; replace it
734            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
735            args_str.replace(", null,", ", options,")
736        } else if args_str.is_empty() {
737            // No options were provided; create new instance with Visitor
738            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
739            "options".to_string()
740        } else {
741            // Fall back to appending options
742            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
743            format!("{args_str}, options")
744        }
745    } else if extra_args_slice.is_empty() {
746        args_str
747    } else if args_str.is_empty() {
748        extra_args_slice.join(", ")
749    } else {
750        format!("{args_str}, {}", extra_args_slice.join(", "))
751    };
752
753    // Always use the base function name (Convert) regardless of visitor presence
754    // The visitor is now handled internally via options.Visitor
755    let effective_function_name = function_name.to_string();
756
757    let return_type = if is_async { "async Task" } else { "void" };
758    let await_kw = if is_async { "await " } else { "" };
759
760    // Client factory: when set, create a client instance and call methods on it
761    // rather than using static class calls.
762    let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
763        e2e_config
764            .call
765            .overrides
766            .get("csharp")
767            .and_then(|o| o.client_factory.as_deref())
768    });
769    let call_target = if client_factory.is_some() {
770        "client".to_string()
771    } else {
772        class_name.to_string()
773    };
774
775    // Build client factory setup code
776    let mut client_factory_setup = String::new();
777    if let Some(factory) = client_factory {
778        let factory_name = factory.to_upper_camel_case();
779        let fixture_id = &fixture.id;
780        client_factory_setup.push_str(&format!("        var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
781        client_factory_setup.push_str(&format!(
782            "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
783        ));
784    }
785
786    // Build call expression
787    let call_expr = format!("{}({})", effective_function_name, final_args);
788
789    // Build assertions body for non-error cases
790    let mut assertions_body = String::new();
791    if !expects_error && !returns_void {
792        for assertion in &fixture.assertions {
793            render_assertion(
794                &mut assertions_body,
795                assertion,
796                result_var,
797                class_name,
798                exception_class,
799                field_resolver,
800                effective_result_is_simple,
801                call_config.result_is_vec || cs_overrides.is_some_and(|o| o.result_is_vec),
802                call_config.result_is_array,
803                effective_result_is_bytes,
804                &e2e_config.fields_enum,
805            );
806        }
807    }
808
809    let ctx = minijinja::context! {
810        is_skipped => false,
811        expects_error => expects_error,
812        description => description,
813        return_type => return_type,
814        method_name => method_name,
815        async_kw => await_kw,
816        call_target => call_target,
817        setup_lines => setup_lines.clone(),
818        call_expr => call_expr,
819        exception_class => exception_class,
820        client_factory_setup => client_factory_setup,
821        has_usable_assertion => !expects_error && !returns_void,
822        result_var => result_var,
823        assertions_body => assertions_body,
824    };
825
826    let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
827    // Indent each line by 4 spaces to nest inside the test class
828    for line in rendered.lines() {
829        out.push_str("    ");
830        out.push_str(line);
831        out.push('\n');
832    }
833}
834
835/// Render a `chat_stream` test method. The C# binding emits
836/// `IAsyncEnumerable<ChatCompletionChunk> ChatStream(req)` (not `Task<T>`), so
837/// the test body uses `await foreach` to drive the stream and aggregates
838/// per-chunk data into local vars (`chunks`, `streamContent`, `streamComplete`,
839/// optional `lastFinishReason`/`toolCallsJson`/`toolCalls0FunctionName`/`totalTokens`).
840/// Assertions then run against those locals — never against pseudo-fields on a
841/// response object.
842#[allow(clippy::too_many_arguments)]
843fn render_chat_stream_test_method(
844    out: &mut String,
845    fixture: &Fixture,
846    class_name: &str,
847    call_config: &crate::config::CallConfig,
848    cs_overrides: Option<&crate::config::CallOverride>,
849    e2e_config: &E2eConfig,
850    enum_fields: &HashMap<String, String>,
851    nested_types: &HashMap<String, String>,
852    exception_class: &str,
853) {
854    let method_name = fixture.id.to_upper_camel_case();
855    let description = &fixture.description;
856    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
857
858    let effective_function_name = cs_overrides
859        .and_then(|o| o.function.as_ref())
860        .cloned()
861        .unwrap_or_else(|| call_config.function.to_upper_camel_case());
862    let function_name = effective_function_name.as_str();
863    let args = call_config.args.as_slice();
864
865    let top_level_options_type = e2e_config
866        .call
867        .overrides
868        .get("csharp")
869        .and_then(|o| o.options_type.as_deref());
870    let effective_options_type = cs_overrides
871        .and_then(|o| o.options_type.as_deref())
872        .or(top_level_options_type);
873    let top_level_options_via = e2e_config
874        .call
875        .overrides
876        .get("csharp")
877        .and_then(|o| o.options_via.as_deref());
878    let effective_options_via = cs_overrides
879        .and_then(|o| o.options_via.as_deref())
880        .or(top_level_options_via);
881
882    let (setup_lines, args_str) = build_args_and_setup(
883        &fixture.input,
884        args,
885        class_name,
886        effective_options_type,
887        effective_options_via,
888        enum_fields,
889        nested_types,
890        &fixture.id,
891    );
892
893    let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
894        e2e_config
895            .call
896            .overrides
897            .get("csharp")
898            .and_then(|o| o.client_factory.as_deref())
899    });
900    let mut client_factory_setup = String::new();
901    if let Some(factory) = client_factory {
902        let factory_name = factory.to_upper_camel_case();
903        let fixture_id = &fixture.id;
904        client_factory_setup.push_str(&format!(
905            "        var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"
906        ));
907        client_factory_setup.push_str(&format!(
908            "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
909        ));
910    }
911
912    let call_target = if client_factory.is_some() { "client" } else { class_name };
913    let call_expr = format!("{call_target}.{function_name}({args_str})");
914
915    // Detect which aggregators a fixture's assertions actually need.
916    let mut needs_finish_reason = false;
917    let mut needs_tool_calls_json = false;
918    let mut needs_tool_calls_0_function_name = false;
919    let mut needs_total_tokens = false;
920    for a in &fixture.assertions {
921        if let Some(f) = a.field.as_deref() {
922            match f {
923                "finish_reason" => needs_finish_reason = true,
924                "tool_calls" => needs_tool_calls_json = true,
925                "tool_calls[0].function.name" => needs_tool_calls_0_function_name = true,
926                "usage.total_tokens" => needs_total_tokens = true,
927                _ => {}
928            }
929        }
930    }
931
932    let mut body = String::new();
933    let _ = writeln!(body, "    [Fact]");
934    let _ = writeln!(body, "    public async Task Test_{method_name}()");
935    let _ = writeln!(body, "    {{");
936    let _ = writeln!(body, "        // {description}");
937    if !client_factory_setup.is_empty() {
938        body.push_str(&client_factory_setup);
939    }
940    for line in &setup_lines {
941        let _ = writeln!(body, "        {line}");
942    }
943
944    if expects_error {
945        // Wrap the foreach in a lambda so the IAsyncEnumerable is actually
946        // consumed (otherwise the producer never runs and no exception is raised).
947        let _ = writeln!(
948            body,
949            "        await Assert.ThrowsAnyAsync<{exception_class}>(async () => {{"
950        );
951        let _ = writeln!(body, "            await foreach (var _chunk in {call_expr}) {{ }}");
952        body.push_str("        });\n");
953        body.push_str("    }\n");
954        for line in body.lines() {
955            out.push_str("    ");
956            out.push_str(line);
957            out.push('\n');
958        }
959        return;
960    }
961
962    body.push_str("        var chunks = new List<ChatCompletionChunk>();\n");
963    body.push_str("        var streamContent = new System.Text.StringBuilder();\n");
964    body.push_str("        var streamComplete = false;\n");
965    if needs_finish_reason {
966        body.push_str("        string? lastFinishReason = null;\n");
967    }
968    if needs_tool_calls_json {
969        body.push_str("        string? toolCallsJson = null;\n");
970    }
971    if needs_tool_calls_0_function_name {
972        body.push_str("        string? toolCalls0FunctionName = null;\n");
973    }
974    if needs_total_tokens {
975        body.push_str("        long? totalTokens = null;\n");
976    }
977    let _ = writeln!(body, "        await foreach (var chunk in {call_expr})");
978    body.push_str("        {\n");
979    body.push_str("            chunks.Add(chunk);\n");
980    body.push_str(
981        "            var choice = chunk.Choices != null && chunk.Choices.Count > 0 ? chunk.Choices[0] : null;\n",
982    );
983    body.push_str("            if (choice != null)\n");
984    body.push_str("            {\n");
985    body.push_str("                var delta = choice.Delta;\n");
986    body.push_str("                if (delta != null && !string.IsNullOrEmpty(delta.Content))\n");
987    body.push_str("                {\n");
988    body.push_str("                    streamContent.Append(delta.Content);\n");
989    body.push_str("                }\n");
990    if needs_finish_reason {
991        body.push_str("                if (choice.FinishReason != null)\n");
992        body.push_str("                {\n");
993        body.push_str("                    lastFinishReason = choice.FinishReason?.ToString()?.ToLower();\n");
994        body.push_str("                }\n");
995    }
996    if needs_tool_calls_json || needs_tool_calls_0_function_name {
997        body.push_str("                var tcs = delta?.ToolCalls;\n");
998        body.push_str("                if (tcs != null && tcs.Count > 0)\n");
999        body.push_str("                {\n");
1000        if needs_tool_calls_json {
1001            body.push_str(
1002                "                    toolCallsJson ??= JsonSerializer.Serialize(tcs.Select(tc => new { function = new { name = tc.Function?.Name } }));\n",
1003            );
1004        }
1005        if needs_tool_calls_0_function_name {
1006            body.push_str("                    toolCalls0FunctionName ??= tcs[0].Function?.Name;\n");
1007        }
1008        body.push_str("                }\n");
1009    }
1010    body.push_str("            }\n");
1011    if needs_total_tokens {
1012        body.push_str("            if (chunk.Usage != null)\n");
1013        body.push_str("            {\n");
1014        body.push_str("                totalTokens = chunk.Usage.TotalTokens;\n");
1015        body.push_str("            }\n");
1016    }
1017    body.push_str("        }\n");
1018    body.push_str("        streamComplete = true;\n");
1019
1020    // Emit assertions on local aggregator vars.
1021    let mut had_explicit_complete = false;
1022    for assertion in &fixture.assertions {
1023        if assertion.field.as_deref() == Some("stream_complete") {
1024            had_explicit_complete = true;
1025        }
1026        emit_chat_stream_assertion(&mut body, assertion);
1027    }
1028    if !had_explicit_complete {
1029        body.push_str("        Assert.True(streamComplete);\n");
1030    }
1031
1032    body.push_str("    }\n");
1033
1034    for line in body.lines() {
1035        out.push_str("    ");
1036        out.push_str(line);
1037        out.push('\n');
1038    }
1039}
1040
1041/// Map a streaming fixture assertion to an `Assert` call on the local aggregator
1042/// variable produced by `render_chat_stream_test_method`. Pseudo-fields like
1043/// `chunks` / `stream_content` / `stream_complete` resolve to in-method locals.
1044fn emit_chat_stream_assertion(out: &mut String, assertion: &Assertion) {
1045    let atype = assertion.assertion_type.as_str();
1046    if atype == "not_error" || atype == "error" {
1047        return;
1048    }
1049    let field = assertion.field.as_deref().unwrap_or("");
1050
1051    enum Kind {
1052        Chunks,
1053        Bool,
1054        Str,
1055        IntTokens,
1056        Json,
1057        Unsupported,
1058    }
1059
1060    let (expr, kind) = match field {
1061        "chunks" => ("chunks", Kind::Chunks),
1062        "stream_content" => ("streamContent.ToString()", Kind::Str),
1063        "stream_complete" => ("streamComplete", Kind::Bool),
1064        "no_chunks_after_done" => ("streamComplete", Kind::Bool),
1065        "finish_reason" => ("lastFinishReason", Kind::Str),
1066        "tool_calls" => ("toolCallsJson", Kind::Json),
1067        "tool_calls[0].function.name" => ("toolCalls0FunctionName", Kind::Str),
1068        "usage.total_tokens" => ("totalTokens", Kind::IntTokens),
1069        _ => ("", Kind::Unsupported),
1070    };
1071
1072    if matches!(kind, Kind::Unsupported) {
1073        let _ = writeln!(
1074            out,
1075            "        // skipped: streaming assertion on unsupported field '{field}'"
1076        );
1077        return;
1078    }
1079
1080    match (atype, &kind) {
1081        ("count_min", Kind::Chunks) => {
1082            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1083                let _ = writeln!(
1084                    out,
1085                    "        Assert.True(chunks.Count >= {n}, \"expected at least {n} chunks\");"
1086                );
1087            }
1088        }
1089        ("count_equals", Kind::Chunks) => {
1090            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1091                let _ = writeln!(out, "        Assert.Equal({n}, chunks.Count);");
1092            }
1093        }
1094        ("equals", Kind::Str) => {
1095            if let Some(val) = &assertion.value {
1096                let cs_val = json_to_csharp(val);
1097                let _ = writeln!(out, "        Assert.Equal({cs_val}, {expr});");
1098            }
1099        }
1100        ("contains", Kind::Str) => {
1101            if let Some(val) = &assertion.value {
1102                let cs_val = json_to_csharp(val);
1103                let _ = writeln!(out, "        Assert.Contains({cs_val}, {expr} ?? string.Empty);");
1104            }
1105        }
1106        ("not_empty", Kind::Str) => {
1107            let _ = writeln!(out, "        Assert.False(string.IsNullOrEmpty({expr}));");
1108        }
1109        ("not_empty", Kind::Json) => {
1110            let _ = writeln!(out, "        Assert.NotNull({expr});");
1111        }
1112        ("is_empty", Kind::Str) => {
1113            let _ = writeln!(out, "        Assert.True(string.IsNullOrEmpty({expr}));");
1114        }
1115        ("is_true", Kind::Bool) => {
1116            let _ = writeln!(out, "        Assert.True({expr});");
1117        }
1118        ("is_false", Kind::Bool) => {
1119            let _ = writeln!(out, "        Assert.False({expr});");
1120        }
1121        ("greater_than_or_equal", Kind::IntTokens) => {
1122            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1123                let _ = writeln!(out, "        Assert.True({expr} >= {n}, \"expected >= {n}\");");
1124            }
1125        }
1126        ("equals", Kind::IntTokens) => {
1127            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1128                let _ = writeln!(out, "        Assert.Equal((long?){n}, {expr});");
1129            }
1130        }
1131        _ => {
1132            let _ = writeln!(
1133                out,
1134                "        // skipped: streaming assertion '{atype}' on field '{field}' not supported"
1135            );
1136        }
1137    }
1138}
1139
1140/// Build setup lines (e.g. handle creation) and the argument list for the function call.
1141///
1142/// Returns `(setup_lines, args_string)`.
1143#[allow(clippy::too_many_arguments)]
1144fn build_args_and_setup(
1145    input: &serde_json::Value,
1146    args: &[crate::config::ArgMapping],
1147    class_name: &str,
1148    options_type: Option<&str>,
1149    options_via: Option<&str>,
1150    enum_fields: &HashMap<String, String>,
1151    nested_types: &HashMap<String, String>,
1152    fixture_id: &str,
1153) -> (Vec<String>, String) {
1154    if args.is_empty() {
1155        return (Vec::new(), String::new());
1156    }
1157
1158    let mut setup_lines: Vec<String> = Vec::new();
1159    let mut parts: Vec<String> = Vec::new();
1160
1161    for arg in args {
1162        if arg.arg_type == "bytes" {
1163            // bytes args must be passed as byte[] in C#.
1164            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1165            let val = input.get(field);
1166            match val {
1167                None | Some(serde_json::Value::Null) if arg.optional => {
1168                    parts.push("null".to_string());
1169                }
1170                None | Some(serde_json::Value::Null) => {
1171                    parts.push("System.Array.Empty<byte>()".to_string());
1172                }
1173                Some(v) => {
1174                    // Classify the value to determine how to interpret it:
1175                    // - File paths (like "pdf/fake.pdf") → File.ReadAllBytes(path)
1176                    // - Inline text → System.Text.Encoding.UTF8.GetBytes()
1177                    // - Base64 → Convert.FromBase64String()
1178                    if let Some(s) = v.as_str() {
1179                        let bytes_code = classify_bytes_value_csharp(s);
1180                        parts.push(bytes_code);
1181                    } else {
1182                        // Literal arrays or other non-string types: use as-is
1183                        let cs_str = json_to_csharp(v);
1184                        parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
1185                    }
1186                }
1187            }
1188            continue;
1189        }
1190
1191        if arg.arg_type == "mock_url" {
1192            setup_lines.push(format!(
1193                "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
1194                arg.name,
1195            ));
1196            parts.push(arg.name.clone());
1197            continue;
1198        }
1199
1200        if arg.arg_type == "handle" {
1201            // Generate a CreateEngine (or equivalent) call and pass the variable.
1202            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
1203            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1204            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1205            if config_value.is_null()
1206                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1207            {
1208                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
1209            } else {
1210                // Sort discriminator fields ("type") to appear first in nested objects so
1211                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
1212                // reading other properties (a requirement as of .NET 8).
1213                let sorted = sort_discriminator_first(config_value.clone());
1214                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1215                let name = &arg.name;
1216                setup_lines.push(format!(
1217                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
1218                    escape_csharp(&json_str),
1219                ));
1220                setup_lines.push(format!(
1221                    "var {} = {class_name}.{constructor_name}({name}Config);",
1222                    arg.name,
1223                    name = name,
1224                ));
1225            }
1226            parts.push(arg.name.clone());
1227            continue;
1228        }
1229
1230        // When field is exactly "input", treat the entire input object as the value.
1231        // This matches the convention used by other language generators (e.g. Go).
1232        let val: Option<&serde_json::Value> = if arg.field == "input" {
1233            Some(input)
1234        } else {
1235            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1236            input.get(field)
1237        };
1238        match val {
1239            None | Some(serde_json::Value::Null) if arg.optional => {
1240                // Optional arg with no fixture value: pass null explicitly since
1241                // C# nullable parameters still require an argument at the call site.
1242                parts.push("null".to_string());
1243                continue;
1244            }
1245            None | Some(serde_json::Value::Null) => {
1246                // Required arg with no fixture value: pass a language-appropriate default.
1247                // For json_object args with a known options_type, use `new OptionsType()`
1248                // so the generated code compiles when the method parameter is non-nullable.
1249                let default_val = match arg.arg_type.as_str() {
1250                    "string" => "\"\"".to_string(),
1251                    "int" | "integer" => "0".to_string(),
1252                    "float" | "number" => "0.0d".to_string(),
1253                    "bool" | "boolean" => "false".to_string(),
1254                    "json_object" => {
1255                        if let Some(opts_type) = options_type {
1256                            format!("new {opts_type}()")
1257                        } else {
1258                            "null".to_string()
1259                        }
1260                    }
1261                    _ => "null".to_string(),
1262                };
1263                parts.push(default_val);
1264            }
1265            Some(v) => {
1266                if arg.arg_type == "json_object" {
1267                    // `options_via = "from_json"`: deserialize the entire value (object,
1268                    // array, or scalar) as the options type. This sidesteps per-field
1269                    // type ambiguity — e.g. `JsonElement?` (untagged unions) or
1270                    // `List<NamedRecord>` whose element type cannot be inferred from
1271                    // JSON shape alone — by delegating to System.Text.Json.
1272                    if options_via == Some("from_json")
1273                        && let Some(opts_type) = options_type
1274                    {
1275                        let sorted = sort_discriminator_first(v.clone());
1276                        let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1277                        let escaped = escape_csharp(&json_str);
1278                        parts.push(format!(
1279                            "JsonSerializer.Deserialize<{opts_type}>(\"{escaped}\", ConfigOptions)!",
1280                        ));
1281                        continue;
1282                    }
1283                    // Array value: generate a typed List<T> based on element_type.
1284                    if let Some(arr) = v.as_array() {
1285                        parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
1286                        continue;
1287                    }
1288                    // Object value with known type: generate idiomatic C# object initializer.
1289                    if let Some(opts_type) = options_type {
1290                        if let Some(obj) = v.as_object() {
1291                            parts.push(csharp_object_initializer(obj, opts_type, enum_fields, nested_types));
1292                            continue;
1293                        }
1294                    }
1295                }
1296                parts.push(json_to_csharp(v));
1297            }
1298        }
1299    }
1300
1301    (setup_lines, parts.join(", "))
1302}
1303
1304/// Convert a JSON array to a typed C# `List<T>` expression.
1305///
1306/// Mapping from `ArgMapping::element_type`:
1307/// - `None` or any string type → `List<string>`
1308/// - `"f32"` → `List<float>` with `(float)` casts
1309/// - `"(String, String)"` → `List<List<string>>` for key-value pair arrays
1310/// - `"BatchBytesItem"` / `"BatchFileItem"` → array of batch item instances
1311fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
1312    match element_type {
1313        Some("BatchBytesItem") => {
1314            let items: Vec<String> = arr
1315                .iter()
1316                .filter_map(|v| v.as_object())
1317                .map(|obj| {
1318                    let content = obj.get("content").and_then(|v| v.as_array());
1319                    let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1320                    let content_code = if let Some(arr) = content {
1321                        let bytes: Vec<String> = arr
1322                            .iter()
1323                            .filter_map(|v| v.as_u64().map(|n| format!("(byte){}", n)))
1324                            .collect();
1325                        format!("new byte[] {{ {} }}", bytes.join(", "))
1326                    } else {
1327                        "new byte[] { }".to_string()
1328                    };
1329                    format!(
1330                        "new BatchBytesItem {{ Content = {}, MimeType = \"{}\" }}",
1331                        content_code, mime_type
1332                    )
1333                })
1334                .collect();
1335            format!("new List<BatchBytesItem>() {{ {} }}", items.join(", "))
1336        }
1337        Some("BatchFileItem") => {
1338            let items: Vec<String> = arr
1339                .iter()
1340                .filter_map(|v| v.as_object())
1341                .map(|obj| {
1342                    let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1343                    format!("new BatchFileItem {{ Path = \"{}\" }}", path)
1344                })
1345                .collect();
1346            format!("new List<BatchFileItem>() {{ {} }}", items.join(", "))
1347        }
1348        Some("f32") => {
1349            let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
1350            format!("new List<float>() {{ {} }}", items.join(", "))
1351        }
1352        Some("(String, String)") => {
1353            let items: Vec<String> = arr
1354                .iter()
1355                .map(|v| {
1356                    let strs: Vec<String> = v
1357                        .as_array()
1358                        .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
1359                    format!("new List<string>() {{ {} }}", strs.join(", "))
1360                })
1361                .collect();
1362            format!("new List<List<string>>() {{ {} }}", items.join(", "))
1363        }
1364        Some(et)
1365            if et != "f32"
1366                && et != "(String, String)"
1367                && et != "string"
1368                && et != "BatchBytesItem"
1369                && et != "BatchFileItem" =>
1370        {
1371            // Class/record types: deserialize each element from JSON
1372            let items: Vec<String> = arr
1373                .iter()
1374                .map(|v| {
1375                    let json_str = serde_json::to_string(v).unwrap_or_default();
1376                    let escaped = escape_csharp(&json_str);
1377                    format!("JsonSerializer.Deserialize<{et}>(\"{escaped}\", ConfigOptions)!")
1378                })
1379                .collect();
1380            format!("new List<{et}>() {{ {} }}", items.join(", "))
1381        }
1382        _ => {
1383            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1384            format!("new List<string>() {{ {} }}", items.join(", "))
1385        }
1386    }
1387}
1388
1389/// Detect if a field path accesses a discriminated union variant in C#.
1390/// Pattern: `metadata.format.<variant_name>.<field_name>`
1391/// Returns: Some((accessor, variant_name, inner_field)) if matched, otherwise None
1392fn parse_discriminated_union_access(field: &str) -> Option<(String, String, String)> {
1393    let parts: Vec<&str> = field.split('.').collect();
1394    if parts.len() >= 3 && parts.len() <= 4 {
1395        // Check if this is metadata.format.{variant}.{field} pattern
1396        if parts[0] == "metadata" && parts[1] == "format" {
1397            let variant_name = parts[2];
1398            // Known C# discriminated union variants (lowercase in fixture paths)
1399            let known_variants = [
1400                "pdf",
1401                "docx",
1402                "excel",
1403                "email",
1404                "pptx",
1405                "archive",
1406                "image",
1407                "xml",
1408                "text",
1409                "html",
1410                "ocr",
1411                "csv",
1412                "bibtex",
1413                "citation",
1414                "fiction_book",
1415                "dbf",
1416                "jats",
1417                "epub",
1418                "pst",
1419                "code",
1420            ];
1421            if known_variants.contains(&variant_name) {
1422                let variant_pascal = variant_name.to_upper_camel_case();
1423                if parts.len() == 4 {
1424                    let inner_field = parts[3];
1425                    return Some((
1426                        format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1427                        variant_pascal,
1428                        inner_field.to_string(),
1429                    ));
1430                } else if parts.len() == 3 {
1431                    // Just accessing the variant itself (no inner field)
1432                    return Some((
1433                        format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1434                        variant_pascal,
1435                        String::new(),
1436                    ));
1437                }
1438            }
1439        }
1440    }
1441    None
1442}
1443
1444/// Render an assertion against a discriminated union variant's inner field.
1445/// `variant_var` is the unwrapped union variant (e.g., `variant` from pattern match).
1446/// `inner_field` is the field to access on the variant's Value (e.g., `sheet_count`).
1447fn render_discriminated_union_assertion(
1448    out: &mut String,
1449    assertion: &Assertion,
1450    variant_var: &str,
1451    inner_field: &str,
1452    _result_is_vec: bool,
1453) {
1454    if inner_field.is_empty() {
1455        return; // No field to assert on
1456    }
1457
1458    let field_pascal = inner_field.to_upper_camel_case();
1459    let field_expr = format!("{variant_var}.Value.{field_pascal}");
1460
1461    match assertion.assertion_type.as_str() {
1462        "equals" => {
1463            if let Some(expected) = &assertion.value {
1464                let cs_val = json_to_csharp(expected);
1465                if expected.is_string() {
1466                    let _ = writeln!(out, "            Assert.Equal({cs_val}, {field_expr}!.Trim());");
1467                } else if expected.as_bool() == Some(true) {
1468                    let _ = writeln!(out, "            Assert.True({field_expr});");
1469                } else if expected.as_bool() == Some(false) {
1470                    let _ = writeln!(out, "            Assert.False({field_expr});");
1471                } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
1472                    let _ = writeln!(out, "            Assert.True({field_expr} == {cs_val});");
1473                } else {
1474                    let _ = writeln!(out, "            Assert.Equal({cs_val}, {field_expr});");
1475                }
1476            }
1477        }
1478        "greater_than_or_equal" => {
1479            if let Some(val) = &assertion.value {
1480                let cs_val = json_to_csharp(val);
1481                let _ = writeln!(
1482                    out,
1483                    "            Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1484                );
1485            }
1486        }
1487        "contains_all" => {
1488            if let Some(values) = &assertion.values {
1489                let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1490                for val in values {
1491                    let lower_val = val.as_str().map(|s| s.to_lowercase());
1492                    let cs_val = lower_val
1493                        .as_deref()
1494                        .map(|s| format!("\"{}\"", escape_csharp(s)))
1495                        .unwrap_or_else(|| json_to_csharp(val));
1496                    let _ = writeln!(out, "            Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1497                }
1498            }
1499        }
1500        "contains" => {
1501            if let Some(expected) = &assertion.value {
1502                let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1503                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
1504                let cs_val = lower_expected
1505                    .as_deref()
1506                    .map(|s| format!("\"{}\"", escape_csharp(s)))
1507                    .unwrap_or_else(|| json_to_csharp(expected));
1508                let _ = writeln!(out, "            Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1509            }
1510        }
1511        "not_empty" => {
1512            let _ = writeln!(out, "            Assert.NotEmpty({field_expr});");
1513        }
1514        "is_empty" => {
1515            let _ = writeln!(out, "            Assert.Empty({field_expr});");
1516        }
1517        _ => {
1518            let _ = writeln!(
1519                out,
1520                "            // skipped: assertion type '{}' not yet supported for discriminated union fields",
1521                assertion.assertion_type
1522            );
1523        }
1524    }
1525}
1526
1527#[allow(clippy::too_many_arguments)]
1528fn render_assertion(
1529    out: &mut String,
1530    assertion: &Assertion,
1531    result_var: &str,
1532    class_name: &str,
1533    exception_class: &str,
1534    field_resolver: &FieldResolver,
1535    result_is_simple: bool,
1536    result_is_vec: bool,
1537    result_is_array: bool,
1538    result_is_bytes: bool,
1539    fields_enum: &std::collections::HashSet<String>,
1540) {
1541    // Byte-buffer returns: emit length-based assertions instead of struct-field
1542    // accessors. The result is a `byte[]` and has no named fields like
1543    // `result.Audio` or `result.Content`.
1544    if result_is_bytes {
1545        match assertion.assertion_type.as_str() {
1546            "not_empty" => {
1547                let _ = writeln!(out, "        Assert.NotEmpty({result_var});");
1548                return;
1549            }
1550            "is_empty" => {
1551                let _ = writeln!(out, "        Assert.Empty({result_var});");
1552                return;
1553            }
1554            "count_equals" | "length_equals" => {
1555                if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1556                    let _ = writeln!(out, "        Assert.Equal({n}, {result_var}.Length);");
1557                }
1558                return;
1559            }
1560            "count_min" | "length_min" => {
1561                if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1562                    let _ = writeln!(out, "        Assert.True({result_var}.Length >= {n});");
1563                }
1564                return;
1565            }
1566            _ => {
1567                // Other assertion types are not meaningful on raw byte buffers;
1568                // emit a comment so the test still compiles but flags unsupported
1569                // assertion types for fixture authors.
1570                let _ = writeln!(
1571                    out,
1572                    "        // skipped: assertion type '{}' not supported on byte[] result",
1573                    assertion.assertion_type
1574                );
1575                return;
1576            }
1577        }
1578    }
1579    // Handle synthetic / derived fields before the is_valid_for_result check
1580    // so they are never treated as struct property accesses on the result.
1581    if let Some(f) = &assertion.field {
1582        match f.as_str() {
1583            "chunks_have_content" => {
1584                let synthetic_pred =
1585                    format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
1586                let synthetic_pred_type = match assertion.assertion_type.as_str() {
1587                    "is_true" => "is_true",
1588                    "is_false" => "is_false",
1589                    _ => {
1590                        out.push_str(&format!(
1591                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
1592                        ));
1593                        return;
1594                    }
1595                };
1596                let rendered = crate::template_env::render(
1597                    "csharp/assertion.jinja",
1598                    minijinja::context! {
1599                        assertion_type => "synthetic_assertion",
1600                        synthetic_pred => synthetic_pred,
1601                        synthetic_pred_type => synthetic_pred_type,
1602                    },
1603                );
1604                out.push_str(&rendered);
1605                return;
1606            }
1607            "chunks_have_embeddings" => {
1608                let synthetic_pred =
1609                    format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
1610                let synthetic_pred_type = match assertion.assertion_type.as_str() {
1611                    "is_true" => "is_true",
1612                    "is_false" => "is_false",
1613                    _ => {
1614                        out.push_str(&format!(
1615                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
1616                        ));
1617                        return;
1618                    }
1619                };
1620                let rendered = crate::template_env::render(
1621                    "csharp/assertion.jinja",
1622                    minijinja::context! {
1623                        assertion_type => "synthetic_assertion",
1624                        synthetic_pred => synthetic_pred,
1625                        synthetic_pred_type => synthetic_pred_type,
1626                    },
1627                );
1628                out.push_str(&rendered);
1629                return;
1630            }
1631            // ---- EmbedResponse virtual fields ----
1632            // embed_texts returns List<List<float>> in C# — no wrapper object.
1633            // result_var is the embedding matrix; use it directly.
1634            "embeddings" => {
1635                match assertion.assertion_type.as_str() {
1636                    "count_equals" => {
1637                        if let Some(val) = &assertion.value {
1638                            if let Some(n) = val.as_u64() {
1639                                let rendered = crate::template_env::render(
1640                                    "csharp/assertion.jinja",
1641                                    minijinja::context! {
1642                                        assertion_type => "synthetic_embeddings_count_equals",
1643                                        synthetic_pred => format!("{result_var}.Count"),
1644                                        n => n,
1645                                    },
1646                                );
1647                                out.push_str(&rendered);
1648                            }
1649                        }
1650                    }
1651                    "count_min" => {
1652                        if let Some(val) = &assertion.value {
1653                            if let Some(n) = val.as_u64() {
1654                                let rendered = crate::template_env::render(
1655                                    "csharp/assertion.jinja",
1656                                    minijinja::context! {
1657                                        assertion_type => "synthetic_embeddings_count_min",
1658                                        synthetic_pred => format!("{result_var}.Count"),
1659                                        n => n,
1660                                    },
1661                                );
1662                                out.push_str(&rendered);
1663                            }
1664                        }
1665                    }
1666                    "not_empty" => {
1667                        let rendered = crate::template_env::render(
1668                            "csharp/assertion.jinja",
1669                            minijinja::context! {
1670                                assertion_type => "synthetic_embeddings_not_empty",
1671                                synthetic_pred => result_var.to_string(),
1672                            },
1673                        );
1674                        out.push_str(&rendered);
1675                    }
1676                    "is_empty" => {
1677                        let rendered = crate::template_env::render(
1678                            "csharp/assertion.jinja",
1679                            minijinja::context! {
1680                                assertion_type => "synthetic_embeddings_is_empty",
1681                                synthetic_pred => result_var.to_string(),
1682                            },
1683                        );
1684                        out.push_str(&rendered);
1685                    }
1686                    _ => {
1687                        out.push_str(
1688                            "        // skipped: unsupported assertion type on synthetic field 'embeddings'\n",
1689                        );
1690                    }
1691                }
1692                return;
1693            }
1694            "embedding_dimensions" => {
1695                let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
1696                match assertion.assertion_type.as_str() {
1697                    "equals" => {
1698                        if let Some(val) = &assertion.value {
1699                            if let Some(n) = val.as_u64() {
1700                                let rendered = crate::template_env::render(
1701                                    "csharp/assertion.jinja",
1702                                    minijinja::context! {
1703                                        assertion_type => "synthetic_embedding_dimensions_equals",
1704                                        synthetic_pred => expr,
1705                                        n => n,
1706                                    },
1707                                );
1708                                out.push_str(&rendered);
1709                            }
1710                        }
1711                    }
1712                    "greater_than" => {
1713                        if let Some(val) = &assertion.value {
1714                            if let Some(n) = val.as_u64() {
1715                                let rendered = crate::template_env::render(
1716                                    "csharp/assertion.jinja",
1717                                    minijinja::context! {
1718                                        assertion_type => "synthetic_embedding_dimensions_greater_than",
1719                                        synthetic_pred => expr,
1720                                        n => n,
1721                                    },
1722                                );
1723                                out.push_str(&rendered);
1724                            }
1725                        }
1726                    }
1727                    _ => {
1728                        out.push_str("        // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n");
1729                    }
1730                }
1731                return;
1732            }
1733            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1734                let synthetic_pred = match f.as_str() {
1735                    "embeddings_valid" => {
1736                        format!("{result_var}.All(e => e.Count > 0)")
1737                    }
1738                    "embeddings_finite" => {
1739                        format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
1740                    }
1741                    "embeddings_non_zero" => {
1742                        format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
1743                    }
1744                    "embeddings_normalized" => {
1745                        format!(
1746                            "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
1747                        )
1748                    }
1749                    _ => unreachable!(),
1750                };
1751                let synthetic_pred_type = match assertion.assertion_type.as_str() {
1752                    "is_true" => "is_true",
1753                    "is_false" => "is_false",
1754                    _ => {
1755                        out.push_str(&format!(
1756                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
1757                        ));
1758                        return;
1759                    }
1760                };
1761                let rendered = crate::template_env::render(
1762                    "csharp/assertion.jinja",
1763                    minijinja::context! {
1764                        assertion_type => "synthetic_assertion",
1765                        synthetic_pred => synthetic_pred,
1766                        synthetic_pred_type => synthetic_pred_type,
1767                    },
1768                );
1769                out.push_str(&rendered);
1770                return;
1771            }
1772            // ---- keywords / keywords_count ----
1773            // C# ExtractionResult does not expose extracted_keywords; skip.
1774            "keywords" | "keywords_count" => {
1775                let skipped_reason = format!("field '{f}' not available on C# ExtractionResult");
1776                let rendered = crate::template_env::render(
1777                    "csharp/assertion.jinja",
1778                    minijinja::context! {
1779                        skipped_reason => skipped_reason,
1780                    },
1781                );
1782                out.push_str(&rendered);
1783                return;
1784            }
1785            _ => {}
1786        }
1787    }
1788
1789    // Skip assertions on fields that don't exist on the result type.
1790    if let Some(f) = &assertion.field {
1791        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1792            let skipped_reason = format!("field '{f}' not available on result type");
1793            let rendered = crate::template_env::render(
1794                "csharp/assertion.jinja",
1795                minijinja::context! {
1796                    skipped_reason => skipped_reason,
1797                },
1798            );
1799            out.push_str(&rendered);
1800            return;
1801        }
1802    }
1803
1804    // For count assertions on list results with no field specified, use the list directly.
1805    // Otherwise, when the result is a List<T>, index into the first element for field access.
1806    let is_count_assertion = matches!(
1807        assertion.assertion_type.as_str(),
1808        "count_equals" | "count_min" | "count_max"
1809    );
1810    let is_no_field = assertion.field.is_none() || assertion.field.as_ref().is_some_and(|f| f.is_empty());
1811    let use_list_directly = result_is_vec && is_count_assertion && is_no_field;
1812
1813    let effective_result_var: String = if result_is_vec && !use_list_directly {
1814        format!("{result_var}[0]")
1815    } else {
1816        result_var.to_string()
1817    };
1818
1819    // Check if this is a discriminated union access (e.g., metadata.format.excel.sheet_count)
1820    let is_discriminated_union = assertion
1821        .field
1822        .as_ref()
1823        .is_some_and(|f| parse_discriminated_union_access(f).is_some());
1824
1825    // For discriminated union assertions, generate pattern-matching wrapper
1826    if is_discriminated_union {
1827        if let Some((_, variant_name, inner_field)) = assertion
1828            .field
1829            .as_ref()
1830            .and_then(|f| parse_discriminated_union_access(f))
1831        {
1832            // Use a unique variable name based on the field hash to avoid shadowing
1833            let mut hasher = std::collections::hash_map::DefaultHasher::new();
1834            inner_field.hash(&mut hasher);
1835            let var_hash = format!("{:x}", hasher.finish());
1836            let variant_var = format!("variant_{}", &var_hash[..8]);
1837            let _ = writeln!(
1838                out,
1839                "        if ({effective_result_var}.Metadata.Format is FormatMetadata.{} {})",
1840                variant_name, &variant_var
1841            );
1842            let _ = writeln!(out, "        {{");
1843            render_discriminated_union_assertion(out, assertion, &variant_var, &inner_field, result_is_vec);
1844            let _ = writeln!(out, "        }}");
1845            let _ = writeln!(out, "        else");
1846            let _ = writeln!(out, "        {{");
1847            let _ = writeln!(
1848                out,
1849                "            Assert.Fail(\"Expected {} format metadata\");",
1850                variant_name.to_lowercase()
1851            );
1852            let _ = writeln!(out, "        }}");
1853            return;
1854        }
1855    }
1856
1857    let field_expr = if result_is_simple {
1858        effective_result_var.clone()
1859    } else {
1860        match &assertion.field {
1861            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
1862            _ => effective_result_var.clone(),
1863        }
1864    };
1865
1866    // Determine if field_expr is a list or complex object that requires JSON serialization
1867    // for string-based assertions (contains, not_contains, etc.). List<T>.ToString() in C#
1868    // returns the type name, not the contents.
1869    let field_needs_json_serialize = if result_is_simple {
1870        // Simple results are scalars, but when they're also arrays (e.g., List<string>),
1871        // JSON-serialize so substring checks see actual content, not the type name.
1872        result_is_array
1873    } else {
1874        match &assertion.field {
1875            Some(f) if !f.is_empty() => field_resolver.is_array(f),
1876            // No field specified — the whole result object; needs serialization when complex.
1877            _ => !result_is_simple,
1878        }
1879    };
1880    // Build the string representation of field_expr for substring-based assertions.
1881    let field_as_str = if field_needs_json_serialize {
1882        format!("JsonSerializer.Serialize({field_expr})")
1883    } else {
1884        format!("{field_expr}.ToString()")
1885    };
1886
1887    // Detect enum-typed fields. C# emits typed enums (e.g. `FinishReason?`) for
1888    // these so the codegen must avoid `.Trim()` (string-only) and instead
1889    // compare via `?.ToString()?.ToLower()` to match snake_case JSON.
1890    let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1891        let resolved = field_resolver.resolve(f);
1892        fields_enum.contains(f) || fields_enum.contains(resolved)
1893    });
1894
1895    match assertion.assertion_type.as_str() {
1896        "equals" => {
1897            if let Some(expected) = &assertion.value {
1898                // Enum field equality bypasses the template (which would emit `.Trim()`,
1899                // a string-only API). Compare lowercase ToString() against the lowercase
1900                // fixture value to match the JSON form.
1901                if field_is_enum && expected.is_string() {
1902                    let s_lower = expected.as_str().map(|s| s.to_lowercase()).unwrap_or_default();
1903                    let _ = writeln!(
1904                        out,
1905                        "        Assert.Equal(\"{}\", {field_expr}?.ToString()?.ToLower());",
1906                        escape_csharp(&s_lower)
1907                    );
1908                    return;
1909                }
1910                let cs_val = json_to_csharp(expected);
1911                let is_string_val = expected.is_string();
1912                let is_bool_true = expected.as_bool() == Some(true);
1913                let is_bool_false = expected.as_bool() == Some(false);
1914                let is_integer_val = expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0);
1915
1916                let rendered = crate::template_env::render(
1917                    "csharp/assertion.jinja",
1918                    minijinja::context! {
1919                        assertion_type => "equals",
1920                        field_expr => field_expr.clone(),
1921                        cs_val => cs_val,
1922                        is_string_val => is_string_val,
1923                        is_bool_true => is_bool_true,
1924                        is_bool_false => is_bool_false,
1925                        is_integer_val => is_integer_val,
1926                    },
1927                );
1928                out.push_str(&rendered);
1929            }
1930        }
1931        "contains" => {
1932            if let Some(expected) = &assertion.value {
1933                // Lowercase both expected and actual so that enum fields (where .ToString()
1934                // returns the PascalCase C# member name like "Anchor") correctly match
1935                // fixture snake_case values like "anchor".  String fields are unaffected
1936                // because lowercasing both sides preserves substring matches.
1937                // List/complex fields use JsonSerializer.Serialize() since List<T>.ToString()
1938                // returns the type name, not the contents.
1939                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
1940                let cs_val = lower_expected
1941                    .as_deref()
1942                    .map(|s| format!("\"{}\"", escape_csharp(s)))
1943                    .unwrap_or_else(|| json_to_csharp(expected));
1944
1945                let rendered = crate::template_env::render(
1946                    "csharp/assertion.jinja",
1947                    minijinja::context! {
1948                        assertion_type => "contains",
1949                        field_as_str => field_as_str.clone(),
1950                        cs_val => cs_val,
1951                    },
1952                );
1953                out.push_str(&rendered);
1954            }
1955        }
1956        "contains_all" => {
1957            if let Some(values) = &assertion.values {
1958                let values_cs_lower: Vec<String> = values
1959                    .iter()
1960                    .map(|val| {
1961                        let lower_val = val.as_str().map(|s| s.to_lowercase());
1962                        lower_val
1963                            .as_deref()
1964                            .map(|s| format!("\"{}\"", escape_csharp(s)))
1965                            .unwrap_or_else(|| json_to_csharp(val))
1966                    })
1967                    .collect();
1968
1969                let rendered = crate::template_env::render(
1970                    "csharp/assertion.jinja",
1971                    minijinja::context! {
1972                        assertion_type => "contains_all",
1973                        field_as_str => field_as_str.clone(),
1974                        values_cs_lower => values_cs_lower,
1975                    },
1976                );
1977                out.push_str(&rendered);
1978            }
1979        }
1980        "not_contains" => {
1981            if let Some(expected) = &assertion.value {
1982                let cs_val = json_to_csharp(expected);
1983
1984                let rendered = crate::template_env::render(
1985                    "csharp/assertion.jinja",
1986                    minijinja::context! {
1987                        assertion_type => "not_contains",
1988                        field_as_str => field_as_str.clone(),
1989                        cs_val => cs_val,
1990                    },
1991                );
1992                out.push_str(&rendered);
1993            }
1994        }
1995        "not_empty" => {
1996            let rendered = crate::template_env::render(
1997                "csharp/assertion.jinja",
1998                minijinja::context! {
1999                    assertion_type => "not_empty",
2000                    field_expr => field_expr.clone(),
2001                    field_needs_json_serialize => field_needs_json_serialize,
2002                },
2003            );
2004            out.push_str(&rendered);
2005        }
2006        "is_empty" => {
2007            let rendered = crate::template_env::render(
2008                "csharp/assertion.jinja",
2009                minijinja::context! {
2010                    assertion_type => "is_empty",
2011                    field_expr => field_expr.clone(),
2012                    field_needs_json_serialize => field_needs_json_serialize,
2013                },
2014            );
2015            out.push_str(&rendered);
2016        }
2017        "contains_any" => {
2018            if let Some(values) = &assertion.values {
2019                let checks: Vec<String> = values
2020                    .iter()
2021                    .map(|v| {
2022                        let cs_val = json_to_csharp(v);
2023                        format!("{field_as_str}.Contains({cs_val})")
2024                    })
2025                    .collect();
2026                let contains_any_expr = checks.join(" || ");
2027
2028                let rendered = crate::template_env::render(
2029                    "csharp/assertion.jinja",
2030                    minijinja::context! {
2031                        assertion_type => "contains_any",
2032                        contains_any_expr => contains_any_expr,
2033                    },
2034                );
2035                out.push_str(&rendered);
2036            }
2037        }
2038        "greater_than" => {
2039            if let Some(val) = &assertion.value {
2040                let cs_val = json_to_csharp(val);
2041
2042                let rendered = crate::template_env::render(
2043                    "csharp/assertion.jinja",
2044                    minijinja::context! {
2045                        assertion_type => "greater_than",
2046                        field_expr => field_expr.clone(),
2047                        cs_val => cs_val,
2048                    },
2049                );
2050                out.push_str(&rendered);
2051            }
2052        }
2053        "less_than" => {
2054            if let Some(val) = &assertion.value {
2055                let cs_val = json_to_csharp(val);
2056
2057                let rendered = crate::template_env::render(
2058                    "csharp/assertion.jinja",
2059                    minijinja::context! {
2060                        assertion_type => "less_than",
2061                        field_expr => field_expr.clone(),
2062                        cs_val => cs_val,
2063                    },
2064                );
2065                out.push_str(&rendered);
2066            }
2067        }
2068        "greater_than_or_equal" => {
2069            if let Some(val) = &assertion.value {
2070                let cs_val = json_to_csharp(val);
2071
2072                let rendered = crate::template_env::render(
2073                    "csharp/assertion.jinja",
2074                    minijinja::context! {
2075                        assertion_type => "greater_than_or_equal",
2076                        field_expr => field_expr.clone(),
2077                        cs_val => cs_val,
2078                    },
2079                );
2080                out.push_str(&rendered);
2081            }
2082        }
2083        "less_than_or_equal" => {
2084            if let Some(val) = &assertion.value {
2085                let cs_val = json_to_csharp(val);
2086
2087                let rendered = crate::template_env::render(
2088                    "csharp/assertion.jinja",
2089                    minijinja::context! {
2090                        assertion_type => "less_than_or_equal",
2091                        field_expr => field_expr.clone(),
2092                        cs_val => cs_val,
2093                    },
2094                );
2095                out.push_str(&rendered);
2096            }
2097        }
2098        "starts_with" => {
2099            if let Some(expected) = &assertion.value {
2100                let cs_val = json_to_csharp(expected);
2101
2102                let rendered = crate::template_env::render(
2103                    "csharp/assertion.jinja",
2104                    minijinja::context! {
2105                        assertion_type => "starts_with",
2106                        field_expr => field_expr.clone(),
2107                        cs_val => cs_val,
2108                    },
2109                );
2110                out.push_str(&rendered);
2111            }
2112        }
2113        "ends_with" => {
2114            if let Some(expected) = &assertion.value {
2115                let cs_val = json_to_csharp(expected);
2116
2117                let rendered = crate::template_env::render(
2118                    "csharp/assertion.jinja",
2119                    minijinja::context! {
2120                        assertion_type => "ends_with",
2121                        field_expr => field_expr.clone(),
2122                        cs_val => cs_val,
2123                    },
2124                );
2125                out.push_str(&rendered);
2126            }
2127        }
2128        "min_length" => {
2129            if let Some(val) = &assertion.value {
2130                if let Some(n) = val.as_u64() {
2131                    let rendered = crate::template_env::render(
2132                        "csharp/assertion.jinja",
2133                        minijinja::context! {
2134                            assertion_type => "min_length",
2135                            field_expr => field_expr.clone(),
2136                            n => n,
2137                        },
2138                    );
2139                    out.push_str(&rendered);
2140                }
2141            }
2142        }
2143        "max_length" => {
2144            if let Some(val) = &assertion.value {
2145                if let Some(n) = val.as_u64() {
2146                    let rendered = crate::template_env::render(
2147                        "csharp/assertion.jinja",
2148                        minijinja::context! {
2149                            assertion_type => "max_length",
2150                            field_expr => field_expr.clone(),
2151                            n => n,
2152                        },
2153                    );
2154                    out.push_str(&rendered);
2155                }
2156            }
2157        }
2158        "count_min" => {
2159            if let Some(val) = &assertion.value {
2160                if let Some(n) = val.as_u64() {
2161                    let rendered = crate::template_env::render(
2162                        "csharp/assertion.jinja",
2163                        minijinja::context! {
2164                            assertion_type => "count_min",
2165                            field_expr => field_expr.clone(),
2166                            n => n,
2167                        },
2168                    );
2169                    out.push_str(&rendered);
2170                }
2171            }
2172        }
2173        "count_equals" => {
2174            if let Some(val) = &assertion.value {
2175                if let Some(n) = val.as_u64() {
2176                    let rendered = crate::template_env::render(
2177                        "csharp/assertion.jinja",
2178                        minijinja::context! {
2179                            assertion_type => "count_equals",
2180                            field_expr => field_expr.clone(),
2181                            n => n,
2182                        },
2183                    );
2184                    out.push_str(&rendered);
2185                }
2186            }
2187        }
2188        "is_true" => {
2189            let rendered = crate::template_env::render(
2190                "csharp/assertion.jinja",
2191                minijinja::context! {
2192                    assertion_type => "is_true",
2193                    field_expr => field_expr.clone(),
2194                },
2195            );
2196            out.push_str(&rendered);
2197        }
2198        "is_false" => {
2199            let rendered = crate::template_env::render(
2200                "csharp/assertion.jinja",
2201                minijinja::context! {
2202                    assertion_type => "is_false",
2203                    field_expr => field_expr.clone(),
2204                },
2205            );
2206            out.push_str(&rendered);
2207        }
2208        "not_error" => {
2209            // Already handled by the call succeeding without exception.
2210            let rendered = crate::template_env::render(
2211                "csharp/assertion.jinja",
2212                minijinja::context! {
2213                    assertion_type => "not_error",
2214                },
2215            );
2216            out.push_str(&rendered);
2217        }
2218        "error" => {
2219            // Handled at the test method level.
2220            let rendered = crate::template_env::render(
2221                "csharp/assertion.jinja",
2222                minijinja::context! {
2223                    assertion_type => "error",
2224                },
2225            );
2226            out.push_str(&rendered);
2227        }
2228        "method_result" => {
2229            if let Some(method_name) = &assertion.method {
2230                let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
2231                let check = assertion.check.as_deref().unwrap_or("is_true");
2232
2233                match check {
2234                    "equals" => {
2235                        if let Some(val) = &assertion.value {
2236                            let is_check_bool_true = val.as_bool() == Some(true);
2237                            let is_check_bool_false = val.as_bool() == Some(false);
2238                            let cs_check_val = json_to_csharp(val);
2239
2240                            let rendered = crate::template_env::render(
2241                                "csharp/assertion.jinja",
2242                                minijinja::context! {
2243                                    assertion_type => "method_result",
2244                                    check => "equals",
2245                                    call_expr => call_expr.clone(),
2246                                    is_check_bool_true => is_check_bool_true,
2247                                    is_check_bool_false => is_check_bool_false,
2248                                    cs_check_val => cs_check_val,
2249                                },
2250                            );
2251                            out.push_str(&rendered);
2252                        }
2253                    }
2254                    "is_true" => {
2255                        let rendered = crate::template_env::render(
2256                            "csharp/assertion.jinja",
2257                            minijinja::context! {
2258                                assertion_type => "method_result",
2259                                check => "is_true",
2260                                call_expr => call_expr.clone(),
2261                            },
2262                        );
2263                        out.push_str(&rendered);
2264                    }
2265                    "is_false" => {
2266                        let rendered = crate::template_env::render(
2267                            "csharp/assertion.jinja",
2268                            minijinja::context! {
2269                                assertion_type => "method_result",
2270                                check => "is_false",
2271                                call_expr => call_expr.clone(),
2272                            },
2273                        );
2274                        out.push_str(&rendered);
2275                    }
2276                    "greater_than_or_equal" => {
2277                        if let Some(val) = &assertion.value {
2278                            let check_n = val.as_u64().unwrap_or(0);
2279
2280                            let rendered = crate::template_env::render(
2281                                "csharp/assertion.jinja",
2282                                minijinja::context! {
2283                                    assertion_type => "method_result",
2284                                    check => "greater_than_or_equal",
2285                                    call_expr => call_expr.clone(),
2286                                    check_n => check_n,
2287                                },
2288                            );
2289                            out.push_str(&rendered);
2290                        }
2291                    }
2292                    "count_min" => {
2293                        if let Some(val) = &assertion.value {
2294                            let check_n = val.as_u64().unwrap_or(0);
2295
2296                            let rendered = crate::template_env::render(
2297                                "csharp/assertion.jinja",
2298                                minijinja::context! {
2299                                    assertion_type => "method_result",
2300                                    check => "count_min",
2301                                    call_expr => call_expr.clone(),
2302                                    check_n => check_n,
2303                                },
2304                            );
2305                            out.push_str(&rendered);
2306                        }
2307                    }
2308                    "is_error" => {
2309                        let rendered = crate::template_env::render(
2310                            "csharp/assertion.jinja",
2311                            minijinja::context! {
2312                                assertion_type => "method_result",
2313                                check => "is_error",
2314                                call_expr => call_expr.clone(),
2315                                exception_class => exception_class,
2316                            },
2317                        );
2318                        out.push_str(&rendered);
2319                    }
2320                    "contains" => {
2321                        if let Some(val) = &assertion.value {
2322                            let cs_check_val = json_to_csharp(val);
2323
2324                            let rendered = crate::template_env::render(
2325                                "csharp/assertion.jinja",
2326                                minijinja::context! {
2327                                    assertion_type => "method_result",
2328                                    check => "contains",
2329                                    call_expr => call_expr.clone(),
2330                                    cs_check_val => cs_check_val,
2331                                },
2332                            );
2333                            out.push_str(&rendered);
2334                        }
2335                    }
2336                    other_check => {
2337                        panic!("C# e2e generator: unsupported method_result check type: {other_check}");
2338                    }
2339                }
2340            } else {
2341                panic!("C# e2e generator: method_result assertion missing 'method' field");
2342            }
2343        }
2344        "matches_regex" => {
2345            if let Some(expected) = &assertion.value {
2346                let cs_val = json_to_csharp(expected);
2347
2348                let rendered = crate::template_env::render(
2349                    "csharp/assertion.jinja",
2350                    minijinja::context! {
2351                        assertion_type => "matches_regex",
2352                        field_expr => field_expr.clone(),
2353                        cs_val => cs_val,
2354                    },
2355                );
2356                out.push_str(&rendered);
2357            }
2358        }
2359        other => {
2360            panic!("C# e2e generator: unsupported assertion type: {other}");
2361        }
2362    }
2363}
2364
2365/// Recursively sort JSON objects so that any key named `"type"` appears first.
2366///
2367/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
2368/// the first property when deserializing polymorphic types. Fixture config values
2369/// serialised via serde_json preserve insertion/alphabetical order, which may put
2370/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
2371fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
2372    match value {
2373        serde_json::Value::Object(map) => {
2374            let mut sorted = serde_json::Map::with_capacity(map.len());
2375            // Insert "type" first if present.
2376            if let Some(type_val) = map.get("type") {
2377                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
2378            }
2379            for (k, v) in map {
2380                if k != "type" {
2381                    sorted.insert(k, sort_discriminator_first(v));
2382                }
2383            }
2384            serde_json::Value::Object(sorted)
2385        }
2386        serde_json::Value::Array(arr) => {
2387            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
2388        }
2389        other => other,
2390    }
2391}
2392
2393/// Convert a `serde_json::Value` to a C# literal string.
2394fn json_to_csharp(value: &serde_json::Value) -> String {
2395    match value {
2396        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
2397        serde_json::Value::Bool(true) => "true".to_string(),
2398        serde_json::Value::Bool(false) => "false".to_string(),
2399        serde_json::Value::Number(n) => {
2400            if n.is_f64() {
2401                format!("{}d", n)
2402            } else {
2403                n.to_string()
2404            }
2405        }
2406        serde_json::Value::Null => "null".to_string(),
2407        serde_json::Value::Array(arr) => {
2408            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
2409            format!("new[] {{ {} }}", items.join(", "))
2410        }
2411        serde_json::Value::Object(_) => {
2412            let json_str = serde_json::to_string(value).unwrap_or_default();
2413            format!("\"{}\"", escape_csharp(&json_str))
2414        }
2415    }
2416}
2417
2418/// Build default nested type mappings for C# extraction config types.
2419///
2420/// Maps known Kreuzberg/Kreuzcrawl config field names (in snake_case) to their
2421/// C# record type names (in PascalCase). These defaults allow e2e codegen to
2422/// automatically deserialize nested config objects without requiring explicit
2423/// configuration in alef.toml. User-provided overrides take precedence.
2424fn default_csharp_nested_types() -> HashMap<String, String> {
2425    [
2426        ("chunking", "ChunkingConfig"),
2427        ("ocr", "OcrConfig"),
2428        ("images", "ImageExtractionConfig"),
2429        ("html_output", "HtmlOutputConfig"),
2430        ("language_detection", "LanguageDetectionConfig"),
2431        ("postprocessor", "PostProcessorConfig"),
2432        ("acceleration", "AccelerationConfig"),
2433        ("email", "EmailConfig"),
2434        ("pages", "PageConfig"),
2435        ("pdf_options", "PdfConfig"),
2436        ("layout", "LayoutDetectionConfig"),
2437        ("tree_sitter", "TreeSitterConfig"),
2438        ("structured_extraction", "StructuredExtractionConfig"),
2439        ("content_filter", "ContentFilterConfig"),
2440        ("token_reduction", "TokenReductionOptions"),
2441        ("security_limits", "SecurityLimits"),
2442        ("format", "FormatMetadata"),
2443    ]
2444    .iter()
2445    .map(|(k, v)| (k.to_string(), v.to_string()))
2446    .collect()
2447}
2448
2449/// Emit a C# object initializer for a JSON options object.
2450///
2451/// - camelCase fixture keys → PascalCase C# property names
2452/// - Enum fields (from `enum_fields`) → `EnumType.Member`
2453/// - Nested objects with known type (from `nested_types`) → `JsonSerializer.Deserialize<T>(...)`
2454/// - Arrays → `new List<string> { ... }`
2455/// - Primitives → C# literals via `json_to_csharp`
2456fn csharp_object_initializer(
2457    obj: &serde_json::Map<String, serde_json::Value>,
2458    type_name: &str,
2459    enum_fields: &HashMap<String, String>,
2460    nested_types: &HashMap<String, String>,
2461) -> String {
2462    if obj.is_empty() {
2463        return format!("new {type_name}()");
2464    }
2465
2466    // Snake_case fixture keys for fields that are real C# enums in the binding.
2467    // The fixture string value (e.g. "markdown") maps to `EnumType.Member` (e.g. `OutputFormat.Markdown`).
2468    static IMPLICIT_ENUM_FIELDS: &[(&str, &str)] = &[("output_format", "OutputFormat")];
2469
2470    let props: Vec<String> = obj
2471        .iter()
2472        .map(|(key, val)| {
2473            let pascal_key = key.to_upper_camel_case();
2474            let implicit_enum_type = IMPLICIT_ENUM_FIELDS
2475                .iter()
2476                .find(|(k, _)| *k == key.as_str())
2477                .map(|(_, t)| *t);
2478            let cs_val =
2479                if let Some(enum_type) = enum_fields.get(key.as_str()).map(String::as_str).or(implicit_enum_type) {
2480                    // Enum: EnumType.Member
2481                    if val.is_null() {
2482                        "null".to_string()
2483                    } else {
2484                        let member = val
2485                            .as_str()
2486                            .map(|s| s.to_upper_camel_case())
2487                            .unwrap_or_else(|| "null".to_string());
2488                        format!("{enum_type}.{member}")
2489                    }
2490                } else if let Some(nested_type) = nested_types.get(key.as_str()) {
2491                    // Nested object: JSON deserialization (keys are typically single-word, matching JsonPropertyName)
2492                    let normalized = normalize_csharp_enum_values(val, enum_fields);
2493                    let json_str = serde_json::to_string(&normalized).unwrap_or_default();
2494                    format!(
2495                        "JsonSerializer.Deserialize<{nested_type}>(\"{}\", ConfigOptions)!",
2496                        escape_csharp(&json_str)
2497                    )
2498                } else if let Some(arr) = val.as_array() {
2499                    // Array: List<string>
2500                    let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
2501                    format!("new List<string> {{ {} }}", items.join(", "))
2502                } else {
2503                    json_to_csharp(val)
2504                };
2505            format!("{pascal_key} = {cs_val}")
2506        })
2507        .collect();
2508    format!("new {} {{ {} }}", type_name, props.join(", "))
2509}
2510
2511/// Convert enum values in a JSON object to lowercase to match C# [JsonPropertyName] attributes.
2512/// The JSON deserialization uses JsonPropertyName("lowercase_value"), so fixture enum values
2513/// (typically PascalCase like "Tildes") must be converted to lowercase ("tildes") for correct
2514/// deserialization with JsonStringEnumConverter.
2515fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
2516    match value {
2517        serde_json::Value::Object(map) => {
2518            let mut result = map.clone();
2519            for (key, val) in result.iter_mut() {
2520                if enum_fields.contains_key(key) {
2521                    // This is an enum field; convert the string value to lowercase.
2522                    if let Some(s) = val.as_str() {
2523                        *val = serde_json::Value::String(s.to_lowercase());
2524                    }
2525                }
2526            }
2527            serde_json::Value::Object(result)
2528        }
2529        other => other.clone(),
2530    }
2531}
2532
2533// ---------------------------------------------------------------------------
2534// Visitor generation
2535// ---------------------------------------------------------------------------
2536
2537/// Build a C# visitor: add an instantiation line to `setup_lines` and push
2538/// a private nested class declaration to `class_decls` (emitted at class scope,
2539/// outside any method body — C# does not allow local class declarations inside
2540/// methods).  Each fixture gets a unique class name derived from its ID to avoid
2541/// duplicate-name compile errors when multiple visitor fixtures exist per file.
2542/// Returns the visitor variable name for use as a call argument.
2543fn build_csharp_visitor(
2544    setup_lines: &mut Vec<String>,
2545    class_decls: &mut Vec<String>,
2546    fixture_id: &str,
2547    visitor_spec: &crate::fixture::VisitorSpec,
2548) -> String {
2549    use heck::ToUpperCamelCase;
2550    let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
2551    let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
2552
2553    setup_lines.push(format!("var {var_name} = new {class_name}();"));
2554
2555    // Build the class declaration string (indented for nesting inside the test class).
2556    let mut decl = String::new();
2557    decl.push_str(&format!("    private sealed class {class_name} : IHtmlVisitor\n"));
2558    decl.push_str("    {\n");
2559
2560    // List of all visitor methods that must be implemented by IHtmlVisitor.
2561    let all_methods = [
2562        "visit_element_start",
2563        "visit_element_end",
2564        "visit_text",
2565        "visit_link",
2566        "visit_image",
2567        "visit_heading",
2568        "visit_code_block",
2569        "visit_code_inline",
2570        "visit_list_item",
2571        "visit_list_start",
2572        "visit_list_end",
2573        "visit_table_start",
2574        "visit_table_row",
2575        "visit_table_end",
2576        "visit_blockquote",
2577        "visit_strong",
2578        "visit_emphasis",
2579        "visit_strikethrough",
2580        "visit_underline",
2581        "visit_subscript",
2582        "visit_superscript",
2583        "visit_mark",
2584        "visit_line_break",
2585        "visit_horizontal_rule",
2586        "visit_custom_element",
2587        "visit_definition_list_start",
2588        "visit_definition_term",
2589        "visit_definition_description",
2590        "visit_definition_list_end",
2591        "visit_form",
2592        "visit_input",
2593        "visit_button",
2594        "visit_audio",
2595        "visit_video",
2596        "visit_iframe",
2597        "visit_details",
2598        "visit_summary",
2599        "visit_figure_start",
2600        "visit_figcaption",
2601        "visit_figure_end",
2602    ];
2603
2604    // Emit all methods: use fixture action if specified, otherwise default to Continue.
2605    for method_name in &all_methods {
2606        if let Some(action) = visitor_spec.callbacks.get(*method_name) {
2607            emit_csharp_visitor_method(&mut decl, method_name, action);
2608        } else {
2609            // Default: Continue for methods not in the fixture
2610            emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
2611        }
2612    }
2613
2614    decl.push_str("    }\n");
2615    class_decls.push(decl);
2616
2617    var_name
2618}
2619
2620/// Emit a C# visitor method into a class declaration string.
2621fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
2622    let camel_method = method_to_camel(method_name);
2623    let params = match method_name {
2624        "visit_link" => "NodeContext ctx, string href, string text, string title",
2625        "visit_image" => "NodeContext ctx, string src, string alt, string title",
2626        "visit_heading" => "NodeContext ctx, uint level, string text, string id",
2627        "visit_code_block" => "NodeContext ctx, string lang, string code",
2628        "visit_code_inline"
2629        | "visit_strong"
2630        | "visit_emphasis"
2631        | "visit_strikethrough"
2632        | "visit_underline"
2633        | "visit_subscript"
2634        | "visit_superscript"
2635        | "visit_mark"
2636        | "visit_button"
2637        | "visit_summary"
2638        | "visit_figcaption"
2639        | "visit_definition_term"
2640        | "visit_definition_description" => "NodeContext ctx, string text",
2641        "visit_text" => "NodeContext ctx, string text",
2642        "visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
2643        "visit_blockquote" => "NodeContext ctx, string content, ulong depth",
2644        "visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
2645        "visit_custom_element" => "NodeContext ctx, string tagName, string html",
2646        "visit_form" => "NodeContext ctx, string actionUrl, string method",
2647        "visit_input" => "NodeContext ctx, string inputType, string name, string value",
2648        "visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
2649        "visit_details" => "NodeContext ctx, bool isOpen",
2650        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
2651            "NodeContext ctx, string output"
2652        }
2653        "visit_list_start" => "NodeContext ctx, bool ordered",
2654        "visit_list_end" => "NodeContext ctx, bool ordered, string output",
2655        "visit_element_start"
2656        | "visit_table_start"
2657        | "visit_definition_list_start"
2658        | "visit_figure_start"
2659        | "visit_line_break"
2660        | "visit_horizontal_rule" => "NodeContext ctx",
2661        _ => "NodeContext ctx",
2662    };
2663
2664    let (action_type, action_value) = match action {
2665        CallbackAction::Skip => ("skip", String::new()),
2666        CallbackAction::Continue => ("continue", String::new()),
2667        CallbackAction::PreserveHtml => ("preserve_html", String::new()),
2668        CallbackAction::Custom { output } => ("custom", escape_csharp(output)),
2669        CallbackAction::CustomTemplate { template } => {
2670            let camel = snake_case_template_to_camel(template);
2671            ("custom_template", escape_csharp(&camel))
2672        }
2673    };
2674
2675    let rendered = crate::template_env::render(
2676        "csharp/visitor_method.jinja",
2677        minijinja::context! {
2678            camel_method => camel_method,
2679            params => params,
2680            action_type => action_type,
2681            action_value => action_value,
2682        },
2683    );
2684    let _ = write!(decl, "{}", rendered);
2685}
2686
2687/// Convert snake_case method names to C# PascalCase.
2688fn method_to_camel(snake: &str) -> String {
2689    use heck::ToUpperCamelCase;
2690    snake.to_upper_camel_case()
2691}
2692
2693/// Rewrite `{snake_case}` placeholders in a custom template to `{camelCase}` so
2694/// they match C# parameter names (which alef emits in camelCase).
2695fn snake_case_template_to_camel(template: &str) -> String {
2696    use heck::ToLowerCamelCase;
2697    let mut out = String::with_capacity(template.len());
2698    let mut chars = template.chars().peekable();
2699    while let Some(c) = chars.next() {
2700        if c == '{' {
2701            let mut name = String::new();
2702            while let Some(&nc) = chars.peek() {
2703                if nc == '}' {
2704                    chars.next();
2705                    break;
2706                }
2707                name.push(nc);
2708                chars.next();
2709            }
2710            out.push('{');
2711            out.push_str(&name.to_lower_camel_case());
2712            out.push('}');
2713        } else {
2714            out.push(c);
2715        }
2716    }
2717    out
2718}
2719
2720/// Build a C# call expression for a `method_result` assertion on a tree-sitter Tree.
2721///
2722/// Maps well-known method names to the appropriate C# static helper calls on the
2723/// generated lib class, falling back to `result_var.PascalCase()` for unknowns.
2724fn build_csharp_method_call(
2725    result_var: &str,
2726    method_name: &str,
2727    args: Option<&serde_json::Value>,
2728    class_name: &str,
2729) -> String {
2730    match method_name {
2731        "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
2732        "root_node_type" => format!("{result_var}.RootNode.Kind"),
2733        "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
2734        "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
2735        "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
2736        "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
2737        "contains_node_type" => {
2738            let node_type = args
2739                .and_then(|a| a.get("node_type"))
2740                .and_then(|v| v.as_str())
2741                .unwrap_or("");
2742            format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
2743        }
2744        "find_nodes_by_type" => {
2745            let node_type = args
2746                .and_then(|a| a.get("node_type"))
2747                .and_then(|v| v.as_str())
2748                .unwrap_or("");
2749            format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
2750        }
2751        "run_query" => {
2752            let query_source = args
2753                .and_then(|a| a.get("query_source"))
2754                .and_then(|v| v.as_str())
2755                .unwrap_or("");
2756            let language = args
2757                .and_then(|a| a.get("language"))
2758                .and_then(|v| v.as_str())
2759                .unwrap_or("");
2760            format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
2761        }
2762        _ => {
2763            use heck::ToUpperCamelCase;
2764            let pascal = method_name.to_upper_camel_case();
2765            format!("{result_var}.{pascal}()")
2766        }
2767    }
2768}
2769
2770fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
2771    // HTTP fixtures are handled separately — not our concern here.
2772    if fixture.is_http_test() {
2773        return false;
2774    }
2775    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
2776    let cs_override = call_config
2777        .overrides
2778        .get("csharp")
2779        .or_else(|| e2e_config.call.overrides.get("csharp"));
2780    // When a client_factory is configured the fixture is callable via the client pattern.
2781    if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
2782        return true;
2783    }
2784    // C# binding provides a default class name (e.g., KreuzcrawlLib) if not overridden,
2785    // so any function name makes a callable available.
2786    cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
2787}
2788
2789/// Classify a fixture string value that maps to a `bytes` argument.
2790/// Determines whether to treat it as a file path, inline text, or base64-encoded data.
2791fn classify_bytes_value_csharp(s: &str) -> String {
2792    // File paths: start with alphanumeric/underscore, contain "/" with extension
2793    // e.g., "pdf/fake.pdf", "images/test.png"
2794    if let Some(first) = s.chars().next() {
2795        if first.is_ascii_alphanumeric() || first == '_' {
2796            if let Some(slash_pos) = s.find('/') {
2797                if slash_pos > 0 {
2798                    let after_slash = &s[slash_pos + 1..];
2799                    if after_slash.contains('.') && !after_slash.is_empty() {
2800                        // File path: use File.ReadAllBytes(path)
2801                        return format!("System.IO.File.ReadAllBytes(\"{}\")", s);
2802                    }
2803                }
2804            }
2805        }
2806    }
2807
2808    // Inline text: starts with markup or contains spaces
2809    // e.g., "<html>...", "{...}", "[...]", "text with spaces"
2810    if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
2811        // Inline text: use System.Text.Encoding.UTF8.GetBytes()
2812        return format!("System.Text.Encoding.UTF8.GetBytes(\"{}\")", escape_csharp(s));
2813    }
2814
2815    // Base64: base64-like pattern (uppercase/lowercase letters, digits, +, /, =)
2816    // e.g., "/9j/4AAQ", "SGVsbG8gV29ybGQ="
2817    // Use Convert.FromBase64String()
2818    format!("System.Convert.FromBase64String(\"{}\")", s)
2819}