Skip to main content

alef_e2e/codegen/
csharp.rs

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