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