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