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};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions as tv;
14use anyhow::Result;
15use heck::ToUpperCamelCase;
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21
22/// C# e2e code generator.
23pub struct CSharpCodegen;
24
25impl E2eCodegen for CSharpCodegen {
26    fn generate(
27        &self,
28        groups: &[FixtureGroup],
29        e2e_config: &E2eConfig,
30        alef_config: &AlefConfig,
31    ) -> Result<Vec<GeneratedFile>> {
32        let lang = self.language_name();
33        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
34
35        let mut files = Vec::new();
36
37        // Resolve call config with overrides.
38        let call = &e2e_config.call;
39        let overrides = call.overrides.get(lang);
40        let function_name = overrides
41            .and_then(|o| o.function.as_ref())
42            .cloned()
43            .unwrap_or_else(|| call.function.to_upper_camel_case());
44        let class_name = overrides
45            .and_then(|o| o.class.as_ref())
46            .cloned()
47            .unwrap_or_else(|| format!("{}Lib", alef_config.crate_config.name.to_upper_camel_case()));
48        // The exception class is always {CrateName}Exception, generated by the C# backend.
49        let exception_class = format!("{}Exception", alef_config.crate_config.name.to_upper_camel_case());
50        let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
51            if call.module.is_empty() {
52                "Kreuzberg".to_string()
53            } else {
54                call.module.to_upper_camel_case()
55            }
56        });
57        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
58        let result_var = &call.result_var;
59        let is_async = call.r#async;
60
61        // Resolve package config.
62        let cs_pkg = e2e_config.resolve_package("csharp");
63        let pkg_name = cs_pkg
64            .as_ref()
65            .and_then(|p| p.name.as_ref())
66            .cloned()
67            .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
68        // The project reference path uses the crate name (with hyphens) for the directory
69        // and the PascalCase name for the .csproj file.
70        let pkg_path = cs_pkg
71            .as_ref()
72            .and_then(|p| p.path.as_ref())
73            .cloned()
74            .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}.csproj"));
75        let pkg_version = cs_pkg
76            .as_ref()
77            .and_then(|p| p.version.as_ref())
78            .cloned()
79            .unwrap_or_else(|| "0.1.0".to_string());
80
81        // Generate the .csproj using a unique name derived from the package name so
82        // it does not conflict with any hand-written project files in the same directory.
83        let csproj_name = format!("{pkg_name}.E2eTests.csproj");
84        files.push(GeneratedFile {
85            path: output_base.join(&csproj_name),
86            content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
87            generated_header: false,
88        });
89
90        // Generate test files per category.
91        let tests_base = output_base.join("tests");
92        let field_resolver = FieldResolver::new(
93            &e2e_config.fields,
94            &e2e_config.fields_optional,
95            &e2e_config.result_fields,
96            &e2e_config.fields_array,
97        );
98
99        // Resolve enum_fields from C# override config.
100        static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
101        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
102
103        for group in groups {
104            let active: Vec<&Fixture> = group
105                .fixtures
106                .iter()
107                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
108                .collect();
109
110            if active.is_empty() {
111                continue;
112            }
113
114            let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
115            let filename = format!("{test_class}.cs");
116            let content = render_test_file(
117                &group.category,
118                &active,
119                &namespace,
120                &class_name,
121                &function_name,
122                &exception_class,
123                result_var,
124                &test_class,
125                &e2e_config.call.args,
126                &field_resolver,
127                result_is_simple,
128                is_async,
129                e2e_config,
130                enum_fields,
131            );
132            files.push(GeneratedFile {
133                path: tests_base.join(filename),
134                content,
135                generated_header: true,
136            });
137        }
138
139        Ok(files)
140    }
141
142    fn language_name(&self) -> &'static str {
143        "csharp"
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Rendering
149// ---------------------------------------------------------------------------
150
151fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
152    let pkg_ref = match dep_mode {
153        crate::config::DependencyMode::Registry => {
154            format!("    <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
155        }
156        crate::config::DependencyMode::Local => {
157            format!("    <ProjectReference Include=\"{pkg_path}\" />")
158        }
159    };
160    format!(
161        r#"<Project Sdk="Microsoft.NET.Sdk">
162  <PropertyGroup>
163    <TargetFramework>net10.0</TargetFramework>
164    <Nullable>enable</Nullable>
165    <ImplicitUsings>enable</ImplicitUsings>
166    <IsPackable>false</IsPackable>
167    <IsTestProject>true</IsTestProject>
168  </PropertyGroup>
169
170  <ItemGroup>
171    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="{ms_test_sdk}" />
172    <PackageReference Include="xunit" Version="{xunit}" />
173    <PackageReference Include="xunit.runner.visualstudio" Version="{xunit_runner}" />
174  </ItemGroup>
175
176  <ItemGroup>
177{pkg_ref}
178  </ItemGroup>
179</Project>
180"#,
181        ms_test_sdk = tv::nuget::MICROSOFT_NET_TEST_SDK,
182        xunit = tv::nuget::XUNIT,
183        xunit_runner = tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
184    )
185}
186
187#[allow(clippy::too_many_arguments)]
188fn render_test_file(
189    category: &str,
190    fixtures: &[&Fixture],
191    namespace: &str,
192    class_name: &str,
193    function_name: &str,
194    exception_class: &str,
195    result_var: &str,
196    test_class: &str,
197    args: &[crate::config::ArgMapping],
198    field_resolver: &FieldResolver,
199    result_is_simple: bool,
200    is_async: bool,
201    e2e_config: &E2eConfig,
202    enum_fields: &HashMap<String, String>,
203) -> String {
204    let mut out = String::new();
205    out.push_str(&hash::header(CommentStyle::DoubleSlash));
206    // Always import System.Text.Json for the shared JsonOptions field.
207    let _ = writeln!(out, "using System.Text.Json;");
208    let _ = writeln!(out, "using System.Text.Json.Serialization;");
209    let _ = writeln!(out, "using System.Threading.Tasks;");
210    let _ = writeln!(out, "using Xunit;");
211    let _ = writeln!(out, "using {namespace};");
212    let _ = writeln!(out);
213    let _ = writeln!(out, "namespace Kreuzberg.E2e;");
214    let _ = writeln!(out);
215    let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
216    let _ = writeln!(out, "public class {test_class}");
217    let _ = writeln!(out, "{{");
218    // Shared options used when deserializing config JSON in test setup.
219    // Mirrors the options used by the library to ensure enum values round-trip correctly.
220    let _ = writeln!(
221        out,
222        "    private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
223    );
224    let _ = writeln!(out);
225
226    for (i, fixture) in fixtures.iter().enumerate() {
227        render_test_method(
228            &mut out,
229            fixture,
230            class_name,
231            function_name,
232            exception_class,
233            result_var,
234            args,
235            field_resolver,
236            result_is_simple,
237            is_async,
238            e2e_config,
239            enum_fields,
240        );
241        if i + 1 < fixtures.len() {
242            let _ = writeln!(out);
243        }
244    }
245
246    let _ = writeln!(out, "}}");
247    out
248}
249
250#[allow(clippy::too_many_arguments)]
251fn render_test_method(
252    out: &mut String,
253    fixture: &Fixture,
254    class_name: &str,
255    _function_name: &str,
256    exception_class: &str,
257    _result_var: &str,
258    _args: &[crate::config::ArgMapping],
259    field_resolver: &FieldResolver,
260    result_is_simple: bool,
261    _is_async: bool,
262    e2e_config: &E2eConfig,
263    enum_fields: &HashMap<String, String>,
264) {
265    let method_name = fixture.id.to_upper_camel_case();
266    let description = &fixture.description;
267    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
268
269    // The C# binding wraps a C FFI layer and does not expose a HandleRequest or equivalent
270    // function that can be called from e2e tests.  Until C#-native HTTP integration tests
271    // are implemented, emit compilable stubs for all fixtures that don't use mock_response.
272    if fixture.mock_response.is_none() {
273        let _ = writeln!(
274            out,
275            "    [Fact(Skip = \"TODO: implement C# e2e tests via the spikard C# binding API\")]"
276        );
277        let _ = writeln!(out, "    public void Test_{method_name}()");
278        let _ = writeln!(out, "    {{");
279        let _ = writeln!(out, "        // {description}");
280        let _ = writeln!(out, "    }}");
281        return;
282    }
283
284    // Resolve call config per-fixture so named calls (e.g. "parse") use the
285    // correct function name, result variable, and async flag.
286    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
287    let lang = "csharp";
288    let cs_overrides = call_config.overrides.get(lang);
289    let effective_function_name = cs_overrides
290        .and_then(|o| o.function.as_ref())
291        .cloned()
292        .unwrap_or_else(|| call_config.function.to_upper_camel_case());
293    let effective_result_var = &call_config.result_var;
294    let effective_is_async = call_config.r#async;
295    let function_name = effective_function_name.as_str();
296    let result_var = effective_result_var.as_str();
297    let is_async = effective_is_async;
298    let args = call_config.args.as_slice();
299
300    // Per-call overrides: result shape, void returns, extra trailing args.
301    let per_call_result_is_simple = cs_overrides.is_some_and(|o| o.result_is_simple);
302    let effective_result_is_simple = result_is_simple || per_call_result_is_simple;
303    let returns_void = call_config.returns_void;
304    let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
305    // options_type: prefer per-call override, fall back to top-level csharp override.
306    let top_level_options_type = e2e_config
307        .call
308        .overrides
309        .get("csharp")
310        .and_then(|o| o.options_type.as_deref());
311    let effective_options_type = cs_overrides
312        .and_then(|o| o.options_type.as_deref())
313        .or(top_level_options_type);
314
315    let (mut setup_lines, args_str) = build_args_and_setup(
316        &fixture.input,
317        args,
318        class_name,
319        effective_options_type,
320        enum_fields,
321        &fixture.id,
322    );
323
324    // Build visitor if present and add to setup
325    let mut visitor_arg = String::new();
326    if let Some(visitor_spec) = &fixture.visitor {
327        visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_spec);
328    }
329
330    let args_with_visitor = if visitor_arg.is_empty() {
331        args_str
332    } else {
333        format!("{args_str}, {visitor_arg}")
334    };
335
336    let final_args = if extra_args_slice.is_empty() {
337        args_with_visitor
338    } else if args_with_visitor.is_empty() {
339        extra_args_slice.join(", ")
340    } else {
341        format!("{args_with_visitor}, {}", extra_args_slice.join(", "))
342    };
343
344    let return_type = if is_async { "async Task" } else { "void" };
345    let await_kw = if is_async { "await " } else { "" };
346
347    let _ = writeln!(out, "    [Fact]");
348    let _ = writeln!(out, "    public {return_type} Test_{method_name}()");
349    let _ = writeln!(out, "    {{");
350    let _ = writeln!(out, "        // {description}");
351
352    for line in &setup_lines {
353        let _ = writeln!(out, "        {line}");
354    }
355
356    if expects_error {
357        if is_async {
358            let _ = writeln!(
359                out,
360                "        await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
361            );
362        } else {
363            let _ = writeln!(
364                out,
365                "        Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
366            );
367        }
368        let _ = writeln!(out, "    }}");
369        return;
370    }
371
372    let result_is_vec = cs_overrides.is_some_and(|o| o.result_is_vec);
373
374    if returns_void {
375        let _ = writeln!(out, "        {await_kw}{class_name}.{function_name}({final_args});");
376    } else {
377        let _ = writeln!(
378            out,
379            "        var {result_var} = {await_kw}{class_name}.{function_name}({final_args});"
380        );
381        for assertion in &fixture.assertions {
382            render_assertion(
383                out,
384                assertion,
385                result_var,
386                class_name,
387                exception_class,
388                field_resolver,
389                effective_result_is_simple,
390                result_is_vec,
391            );
392        }
393    }
394
395    let _ = writeln!(out, "    }}");
396}
397
398/// Build setup lines (e.g. handle creation) and the argument list for the function call.
399///
400/// Returns `(setup_lines, args_string)`.
401fn build_args_and_setup(
402    input: &serde_json::Value,
403    args: &[crate::config::ArgMapping],
404    class_name: &str,
405    options_type: Option<&str>,
406    _enum_fields: &HashMap<String, String>,
407    fixture_id: &str,
408) -> (Vec<String>, String) {
409    if args.is_empty() {
410        return (Vec::new(), String::new());
411    }
412
413    let mut setup_lines: Vec<String> = Vec::new();
414    let mut parts: Vec<String> = Vec::new();
415
416    for arg in args {
417        if arg.arg_type == "bytes" {
418            // bytes args must be passed as byte[] in C#.
419            // Treat the fixture value as a UTF-8 string and convert to bytes.
420            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
421            let val = input.get(field);
422            match val {
423                None | Some(serde_json::Value::Null) if arg.optional => {
424                    parts.push("null".to_string());
425                }
426                None | Some(serde_json::Value::Null) => {
427                    parts.push("System.Array.Empty<byte>()".to_string());
428                }
429                Some(v) => {
430                    let cs_str = json_to_csharp(v);
431                    parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
432                }
433            }
434            continue;
435        }
436
437        if arg.arg_type == "mock_url" {
438            setup_lines.push(format!(
439                "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
440                arg.name,
441            ));
442            parts.push(arg.name.clone());
443            continue;
444        }
445
446        if arg.arg_type == "handle" {
447            // Generate a CreateEngine (or equivalent) call and pass the variable.
448            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
449            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
450            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
451            if config_value.is_null()
452                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
453            {
454                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
455            } else {
456                // Sort discriminator fields ("type") to appear first in nested objects so
457                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
458                // reading other properties (a requirement as of .NET 8).
459                let sorted = sort_discriminator_first(config_value.clone());
460                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
461                let name = &arg.name;
462                setup_lines.push(format!(
463                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
464                    escape_csharp(&json_str),
465                ));
466                setup_lines.push(format!(
467                    "var {} = {class_name}.{constructor_name}({name}Config);",
468                    arg.name,
469                    name = name,
470                ));
471            }
472            parts.push(arg.name.clone());
473            continue;
474        }
475
476        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
477        let val = input.get(field);
478        match val {
479            None | Some(serde_json::Value::Null) if arg.optional => {
480                // Optional arg with no fixture value: pass null explicitly since
481                // C# nullable parameters still require an argument at the call site.
482                parts.push("null".to_string());
483                continue;
484            }
485            None | Some(serde_json::Value::Null) => {
486                // Required arg with no fixture value: pass a language-appropriate default.
487                let default_val = match arg.arg_type.as_str() {
488                    "string" => "\"\"".to_string(),
489                    "int" | "integer" => "0".to_string(),
490                    "float" | "number" => "0.0d".to_string(),
491                    "bool" | "boolean" => "false".to_string(),
492                    _ => "null".to_string(),
493                };
494                parts.push(default_val);
495            }
496            Some(v) => {
497                if arg.arg_type == "json_object" {
498                    // Array value: generate a typed List<T> based on element_type.
499                    if let Some(arr) = v.as_array() {
500                        parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
501                        continue;
502                    }
503                    // Object value with known type: deserialize via JsonSerializer so the
504                    // library's own [JsonPropertyName] annotations handle field name mapping.
505                    if let Some(opts_type) = options_type {
506                        if v.is_object() {
507                            let json_str = serde_json::to_string(v).unwrap_or_default();
508                            parts.push(format!(
509                                "JsonSerializer.Deserialize<{opts_type}>(\"{}\", ConfigOptions)!",
510                                escape_csharp(&json_str),
511                            ));
512                            continue;
513                        }
514                    }
515                }
516                parts.push(json_to_csharp(v));
517            }
518        }
519    }
520
521    (setup_lines, parts.join(", "))
522}
523
524/// Convert a JSON array to a typed C# `List<T>` expression.
525///
526/// Mapping from `ArgMapping::element_type`:
527/// - `None` or any string type → `List<string>`
528/// - `"f32"` → `List<float>` with `(float)` casts
529/// - `"(String, String)"` → `List<List<string>>` for key-value pair arrays
530fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
531    match element_type {
532        Some("f32") => {
533            let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
534            format!("new List<float>() {{ {} }}", items.join(", "))
535        }
536        Some("(String, String)") => {
537            let items: Vec<String> = arr
538                .iter()
539                .map(|v| {
540                    let strs: Vec<String> = v
541                        .as_array()
542                        .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
543                    format!("new List<string>() {{ {} }}", strs.join(", "))
544                })
545                .collect();
546            format!("new List<List<string>>() {{ {} }}", items.join(", "))
547        }
548        _ => {
549            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
550            format!("new List<string>() {{ {} }}", items.join(", "))
551        }
552    }
553}
554
555#[allow(clippy::too_many_arguments)]
556fn render_assertion(
557    out: &mut String,
558    assertion: &Assertion,
559    result_var: &str,
560    class_name: &str,
561    exception_class: &str,
562    field_resolver: &FieldResolver,
563    result_is_simple: bool,
564    result_is_vec: bool,
565) {
566    // Skip assertions on fields that don't exist on the result type.
567    if let Some(f) = &assertion.field {
568        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
569            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
570            return;
571        }
572    }
573
574    // When the result is a List<T>, index into the first element for field access.
575    let effective_result_var: String = if result_is_vec {
576        format!("{result_var}[0]")
577    } else {
578        result_var.to_string()
579    };
580
581    let field_expr = if result_is_simple {
582        effective_result_var.clone()
583    } else {
584        match &assertion.field {
585            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
586            _ => effective_result_var.clone(),
587        }
588    };
589
590    match assertion.assertion_type.as_str() {
591        "equals" => {
592            if let Some(expected) = &assertion.value {
593                let cs_val = json_to_csharp(expected);
594                if expected.is_string() {
595                    // Only call .Trim() on string fields.
596                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr}.Trim());");
597                } else if expected.as_bool() == Some(true) {
598                    // Boolean true: use Assert.True to avoid xUnit2004 warning.
599                    let _ = writeln!(out, "        Assert.True({field_expr});");
600                } else if expected.as_bool() == Some(false) {
601                    // Boolean false: use Assert.False to avoid xUnit2004 warning.
602                    let _ = writeln!(out, "        Assert.False({field_expr});");
603                } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
604                    // Integer values: use Assert.True(x == n) to avoid xUnit overload
605                    // resolution ambiguity (int vs uint vs long vs DateTime).
606                    let _ = writeln!(out, "        Assert.True({field_expr} == {cs_val});");
607                } else {
608                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr});");
609                }
610            }
611        }
612        "contains" => {
613            if let Some(expected) = &assertion.value {
614                // Lowercase both expected and actual so that enum fields (where .ToString()
615                // returns the PascalCase C# member name like "Anchor") correctly match
616                // fixture snake_case values like "anchor".  String fields are unaffected
617                // because lowercasing both sides preserves substring matches.
618                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
619                let cs_val = lower_expected
620                    .as_deref()
621                    .map(|s| format!("\"{}\"", escape_csharp(s)))
622                    .unwrap_or_else(|| json_to_csharp(expected));
623                let _ = writeln!(
624                    out,
625                    "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
626                );
627            }
628        }
629        "contains_all" => {
630            if let Some(values) = &assertion.values {
631                for val in values {
632                    let lower_val = val.as_str().map(|s| s.to_lowercase());
633                    let cs_val = lower_val
634                        .as_deref()
635                        .map(|s| format!("\"{}\"", escape_csharp(s)))
636                        .unwrap_or_else(|| json_to_csharp(val));
637                    let _ = writeln!(
638                        out,
639                        "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
640                    );
641                }
642            }
643        }
644        "not_contains" => {
645            if let Some(expected) = &assertion.value {
646                let cs_val = json_to_csharp(expected);
647                let _ = writeln!(out, "        Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
648            }
649        }
650        "not_empty" => {
651            let _ = writeln!(
652                out,
653                "        Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
654            );
655        }
656        "is_empty" => {
657            let _ = writeln!(
658                out,
659                "        Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
660            );
661        }
662        "contains_any" => {
663            if let Some(values) = &assertion.values {
664                let checks: Vec<String> = values
665                    .iter()
666                    .map(|v| {
667                        let cs_val = json_to_csharp(v);
668                        format!("{field_expr}.ToString().Contains({cs_val})")
669                    })
670                    .collect();
671                let joined = checks.join(" || ");
672                let _ = writeln!(
673                    out,
674                    "        Assert.True({joined}, \"expected to contain at least one of the specified values\");"
675                );
676            }
677        }
678        "greater_than" => {
679            if let Some(val) = &assertion.value {
680                let cs_val = json_to_csharp(val);
681                let _ = writeln!(
682                    out,
683                    "        Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
684                );
685            }
686        }
687        "less_than" => {
688            if let Some(val) = &assertion.value {
689                let cs_val = json_to_csharp(val);
690                let _ = writeln!(
691                    out,
692                    "        Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
693                );
694            }
695        }
696        "greater_than_or_equal" => {
697            if let Some(val) = &assertion.value {
698                let cs_val = json_to_csharp(val);
699                let _ = writeln!(
700                    out,
701                    "        Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
702                );
703            }
704        }
705        "less_than_or_equal" => {
706            if let Some(val) = &assertion.value {
707                let cs_val = json_to_csharp(val);
708                let _ = writeln!(
709                    out,
710                    "        Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
711                );
712            }
713        }
714        "starts_with" => {
715            if let Some(expected) = &assertion.value {
716                let cs_val = json_to_csharp(expected);
717                let _ = writeln!(out, "        Assert.StartsWith({cs_val}, {field_expr});");
718            }
719        }
720        "ends_with" => {
721            if let Some(expected) = &assertion.value {
722                let cs_val = json_to_csharp(expected);
723                let _ = writeln!(out, "        Assert.EndsWith({cs_val}, {field_expr});");
724            }
725        }
726        "min_length" => {
727            if let Some(val) = &assertion.value {
728                if let Some(n) = val.as_u64() {
729                    let _ = writeln!(
730                        out,
731                        "        Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
732                    );
733                }
734            }
735        }
736        "max_length" => {
737            if let Some(val) = &assertion.value {
738                if let Some(n) = val.as_u64() {
739                    let _ = writeln!(
740                        out,
741                        "        Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
742                    );
743                }
744            }
745        }
746        "count_min" => {
747            if let Some(val) = &assertion.value {
748                if let Some(n) = val.as_u64() {
749                    let _ = writeln!(
750                        out,
751                        "        Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
752                    );
753                }
754            }
755        }
756        "count_equals" => {
757            if let Some(val) = &assertion.value {
758                if let Some(n) = val.as_u64() {
759                    let _ = writeln!(out, "        Assert.Equal({n}, {field_expr}.Count);");
760                }
761            }
762        }
763        "is_true" => {
764            let _ = writeln!(out, "        Assert.True({field_expr});");
765        }
766        "is_false" => {
767            let _ = writeln!(out, "        Assert.False({field_expr});");
768        }
769        "not_error" => {
770            // Already handled by the call succeeding without exception.
771        }
772        "error" => {
773            // Handled at the test method level.
774        }
775        "method_result" => {
776            if let Some(method_name) = &assertion.method {
777                let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
778                let check = assertion.check.as_deref().unwrap_or("is_true");
779                match check {
780                    "equals" => {
781                        if let Some(val) = &assertion.value {
782                            if val.as_bool() == Some(true) {
783                                let _ = writeln!(out, "        Assert.True({call_expr});");
784                            } else if val.as_bool() == Some(false) {
785                                let _ = writeln!(out, "        Assert.False({call_expr});");
786                            } else {
787                                let cs_val = json_to_csharp(val);
788                                let _ = writeln!(out, "        Assert.Equal({cs_val}, {call_expr});");
789                            }
790                        }
791                    }
792                    "is_true" => {
793                        let _ = writeln!(out, "        Assert.True({call_expr});");
794                    }
795                    "is_false" => {
796                        let _ = writeln!(out, "        Assert.False({call_expr});");
797                    }
798                    "greater_than_or_equal" => {
799                        if let Some(val) = &assertion.value {
800                            let n = val.as_u64().unwrap_or(0);
801                            let _ = writeln!(out, "        Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
802                        }
803                    }
804                    "count_min" => {
805                        if let Some(val) = &assertion.value {
806                            let n = val.as_u64().unwrap_or(0);
807                            let _ = writeln!(
808                                out,
809                                "        Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
810                            );
811                        }
812                    }
813                    "is_error" => {
814                        let _ = writeln!(
815                            out,
816                            "        Assert.Throws<{exception_class}>(() => {{ {call_expr}; }});"
817                        );
818                    }
819                    "contains" => {
820                        if let Some(val) = &assertion.value {
821                            let cs_val = json_to_csharp(val);
822                            let _ = writeln!(out, "        Assert.Contains({cs_val}, {call_expr});");
823                        }
824                    }
825                    other_check => {
826                        panic!("C# e2e generator: unsupported method_result check type: {other_check}");
827                    }
828                }
829            } else {
830                panic!("C# e2e generator: method_result assertion missing 'method' field");
831            }
832        }
833        "matches_regex" => {
834            if let Some(expected) = &assertion.value {
835                let cs_val = json_to_csharp(expected);
836                let _ = writeln!(out, "        Assert.Matches({cs_val}, {field_expr});");
837            }
838        }
839        other => {
840            panic!("C# e2e generator: unsupported assertion type: {other}");
841        }
842    }
843}
844
845/// Recursively sort JSON objects so that any key named `"type"` appears first.
846///
847/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
848/// the first property when deserializing polymorphic types. Fixture config values
849/// serialised via serde_json preserve insertion/alphabetical order, which may put
850/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
851fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
852    match value {
853        serde_json::Value::Object(map) => {
854            let mut sorted = serde_json::Map::with_capacity(map.len());
855            // Insert "type" first if present.
856            if let Some(type_val) = map.get("type") {
857                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
858            }
859            for (k, v) in map {
860                if k != "type" {
861                    sorted.insert(k, sort_discriminator_first(v));
862                }
863            }
864            serde_json::Value::Object(sorted)
865        }
866        serde_json::Value::Array(arr) => {
867            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
868        }
869        other => other,
870    }
871}
872
873/// Convert a `serde_json::Value` to a C# literal string.
874fn json_to_csharp(value: &serde_json::Value) -> String {
875    match value {
876        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
877        serde_json::Value::Bool(true) => "true".to_string(),
878        serde_json::Value::Bool(false) => "false".to_string(),
879        serde_json::Value::Number(n) => {
880            if n.is_f64() {
881                format!("{}d", n)
882            } else {
883                n.to_string()
884            }
885        }
886        serde_json::Value::Null => "null".to_string(),
887        serde_json::Value::Array(arr) => {
888            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
889            format!("new[] {{ {} }}", items.join(", "))
890        }
891        serde_json::Value::Object(_) => {
892            let json_str = serde_json::to_string(value).unwrap_or_default();
893            format!("\"{}\"", escape_csharp(&json_str))
894        }
895    }
896}
897
898// ---------------------------------------------------------------------------
899// Visitor generation
900// ---------------------------------------------------------------------------
901
902/// Build a C# visitor class and add setup lines. Returns the visitor variable name.
903fn build_csharp_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
904    setup_lines.push("var _testVisitor = new TestVisitor();".to_string());
905    setup_lines.push("class TestVisitor : IVisitor".to_string());
906    setup_lines.push("{".to_string());
907    for (method_name, action) in &visitor_spec.callbacks {
908        emit_csharp_visitor_method(setup_lines, method_name, action);
909    }
910    setup_lines.push("}".to_string());
911    "_testVisitor".to_string()
912}
913
914/// Emit a C# visitor method for a callback action.
915fn emit_csharp_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
916    let camel_method = method_to_camel(method_name);
917    let params = match method_name {
918        "visit_link" => "VisitContext ctx, string href, string text, string title",
919        "visit_image" => "VisitContext ctx, string src, string alt, string title",
920        "visit_heading" => "VisitContext ctx, int level, string text, string id",
921        "visit_code_block" => "VisitContext ctx, string lang, string code",
922        "visit_code_inline"
923        | "visit_strong"
924        | "visit_emphasis"
925        | "visit_strikethrough"
926        | "visit_underline"
927        | "visit_subscript"
928        | "visit_superscript"
929        | "visit_mark"
930        | "visit_button"
931        | "visit_summary"
932        | "visit_figcaption"
933        | "visit_definition_term"
934        | "visit_definition_description" => "VisitContext ctx, string text",
935        "visit_text" => "VisitContext ctx, string text",
936        "visit_list_item" => "VisitContext ctx, bool ordered, string marker, string text",
937        "visit_blockquote" => "VisitContext ctx, string content, int depth",
938        "visit_table_row" => "VisitContext ctx, IReadOnlyList<string> cells, bool isHeader",
939        "visit_custom_element" => "VisitContext ctx, string tagName, string html",
940        "visit_form" => "VisitContext ctx, string actionUrl, string method",
941        "visit_input" => "VisitContext ctx, string inputType, string name, string value",
942        "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, string src",
943        "visit_details" => "VisitContext ctx, bool isOpen",
944        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
945            "VisitContext ctx, string output"
946        }
947        "visit_list_start" => "VisitContext ctx, bool ordered",
948        "visit_list_end" => "VisitContext ctx, bool ordered, string output",
949        _ => "VisitContext ctx",
950    };
951
952    setup_lines.push(format!("    public VisitResult {camel_method}({params})"));
953    setup_lines.push("    {".to_string());
954    match action {
955        CallbackAction::Skip => {
956            setup_lines.push("        return VisitResult.Skip();".to_string());
957        }
958        CallbackAction::Continue => {
959            setup_lines.push("        return VisitResult.Continue();".to_string());
960        }
961        CallbackAction::PreserveHtml => {
962            setup_lines.push("        return VisitResult.PreserveHtml();".to_string());
963        }
964        CallbackAction::Custom { output } => {
965            let escaped = escape_csharp(output);
966            setup_lines.push(format!("        return VisitResult.Custom(\"{escaped}\");"));
967        }
968        CallbackAction::CustomTemplate { template } => {
969            let escaped = escape_csharp(template);
970            setup_lines.push(format!("        return VisitResult.Custom($\"{escaped}\");"));
971        }
972    }
973    setup_lines.push("    }".to_string());
974}
975
976/// Convert snake_case method names to C# PascalCase.
977fn method_to_camel(snake: &str) -> String {
978    use heck::ToUpperCamelCase;
979    snake.to_upper_camel_case()
980}
981
982/// Build a C# call expression for a `method_result` assertion on a tree-sitter Tree.
983///
984/// Maps well-known method names to the appropriate C# static helper calls on the
985/// generated lib class, falling back to `result_var.PascalCase()` for unknowns.
986fn build_csharp_method_call(
987    result_var: &str,
988    method_name: &str,
989    args: Option<&serde_json::Value>,
990    class_name: &str,
991) -> String {
992    match method_name {
993        "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
994        "root_node_type" => format!("{result_var}.RootNode.Kind"),
995        "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
996        "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
997        "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
998        "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
999        "contains_node_type" => {
1000            let node_type = args
1001                .and_then(|a| a.get("node_type"))
1002                .and_then(|v| v.as_str())
1003                .unwrap_or("");
1004            format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
1005        }
1006        "find_nodes_by_type" => {
1007            let node_type = args
1008                .and_then(|a| a.get("node_type"))
1009                .and_then(|v| v.as_str())
1010                .unwrap_or("");
1011            format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
1012        }
1013        "run_query" => {
1014            let query_source = args
1015                .and_then(|a| a.get("query_source"))
1016                .and_then(|v| v.as_str())
1017                .unwrap_or("");
1018            let language = args
1019                .and_then(|a| a.get("language"))
1020                .and_then(|v| v.as_str())
1021                .unwrap_or("");
1022            format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1023        }
1024        _ => {
1025            use heck::ToUpperCamelCase;
1026            let pascal = method_name.to_upper_camel_case();
1027            format!("{result_var}.{pascal}()")
1028        }
1029    }
1030}