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