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, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use anyhow::Result;
13use heck::ToUpperCamelCase;
14use std::collections::HashMap;
15use std::fmt::Write as FmtWrite;
16use std::path::PathBuf;
17
18use super::E2eCodegen;
19
20/// C# e2e code generator.
21pub struct CSharpCodegen;
22
23impl E2eCodegen for CSharpCodegen {
24    fn generate(
25        &self,
26        groups: &[FixtureGroup],
27        e2e_config: &E2eConfig,
28        alef_config: &AlefConfig,
29    ) -> Result<Vec<GeneratedFile>> {
30        let lang = self.language_name();
31        let output_base = PathBuf::from(&e2e_config.output).join(lang);
32
33        let mut files = Vec::new();
34
35        // Resolve call config with overrides.
36        let call = &e2e_config.call;
37        let overrides = call.overrides.get(lang);
38        let function_name = overrides
39            .and_then(|o| o.function.as_ref())
40            .cloned()
41            .unwrap_or_else(|| call.function.to_upper_camel_case());
42        let class_name = overrides
43            .and_then(|o| o.class.as_ref())
44            .cloned()
45            .unwrap_or_else(|| format!("{}Lib", alef_config.crate_config.name.to_upper_camel_case()));
46        // The exception class is always {CrateName}Exception, generated by the C# backend.
47        let exception_class = format!("{}Exception", alef_config.crate_config.name.to_upper_camel_case());
48        let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
49            if call.module.is_empty() {
50                "Kreuzberg".to_string()
51            } else {
52                call.module.to_upper_camel_case()
53            }
54        });
55        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
56        let result_var = &call.result_var;
57        let is_async = call.r#async;
58
59        // Resolve package config.
60        let cs_pkg = e2e_config.packages.get("csharp");
61        let pkg_name = cs_pkg
62            .and_then(|p| p.name.as_ref())
63            .cloned()
64            .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
65        // The project reference path uses the crate name (with hyphens) for the directory
66        // and the PascalCase name for the .csproj file.
67        let pkg_path = cs_pkg.and_then(|p| p.path.as_ref()).cloned().unwrap_or_else(|| {
68            let dir_name = &alef_config.crate_config.name;
69            format!("../../packages/csharp/{dir_name}/{pkg_name}.csproj")
70        });
71
72        // Generate E2eTests.csproj.
73        files.push(GeneratedFile {
74            path: output_base.join("E2eTests.csproj"),
75            content: render_csproj(&pkg_name, &pkg_path),
76            generated_header: false,
77        });
78
79        // Generate test files per category.
80        let tests_base = output_base.join("tests");
81        let field_resolver = FieldResolver::new(
82            &e2e_config.fields,
83            &e2e_config.fields_optional,
84            &e2e_config.result_fields,
85            &e2e_config.fields_array,
86        );
87
88        // Resolve enum_fields from C# override config.
89        static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
90        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
91
92        for group in groups {
93            let active: Vec<&Fixture> = group
94                .fixtures
95                .iter()
96                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
97                .collect();
98
99            if active.is_empty() {
100                continue;
101            }
102
103            let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
104            let filename = format!("{test_class}.cs");
105            let content = render_test_file(
106                &group.category,
107                &active,
108                &namespace,
109                &class_name,
110                &function_name,
111                &exception_class,
112                result_var,
113                &test_class,
114                &e2e_config.call.args,
115                &field_resolver,
116                result_is_simple,
117                is_async,
118                e2e_config,
119                enum_fields,
120            );
121            files.push(GeneratedFile {
122                path: tests_base.join(filename),
123                content,
124                generated_header: true,
125            });
126        }
127
128        Ok(files)
129    }
130
131    fn language_name(&self) -> &'static str {
132        "csharp"
133    }
134}
135
136// ---------------------------------------------------------------------------
137// Rendering
138// ---------------------------------------------------------------------------
139
140fn render_csproj(_pkg_name: &str, pkg_path: &str) -> String {
141    format!(
142        r#"<Project Sdk="Microsoft.NET.Sdk">
143  <PropertyGroup>
144    <TargetFramework>net10.0</TargetFramework>
145    <Nullable>enable</Nullable>
146    <ImplicitUsings>enable</ImplicitUsings>
147    <IsPackable>false</IsPackable>
148    <IsTestProject>true</IsTestProject>
149  </PropertyGroup>
150
151  <ItemGroup>
152    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
153    <PackageReference Include="xunit" Version="2.9.3" />
154    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
155  </ItemGroup>
156
157  <ItemGroup>
158    <ProjectReference Include="{pkg_path}" />
159  </ItemGroup>
160</Project>
161"#
162    )
163}
164
165#[allow(clippy::too_many_arguments)]
166fn render_test_file(
167    category: &str,
168    fixtures: &[&Fixture],
169    namespace: &str,
170    class_name: &str,
171    function_name: &str,
172    exception_class: &str,
173    result_var: &str,
174    test_class: &str,
175    args: &[crate::config::ArgMapping],
176    field_resolver: &FieldResolver,
177    result_is_simple: bool,
178    is_async: bool,
179    e2e_config: &E2eConfig,
180    enum_fields: &HashMap<String, String>,
181) -> String {
182    let mut out = String::new();
183    let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
184    // Always import System.Text.Json for the shared JsonOptions field.
185    let _ = writeln!(out, "using System.Text.Json;");
186    let _ = writeln!(out, "using System.Text.Json.Serialization;");
187    let _ = writeln!(out, "using System.Threading.Tasks;");
188    let _ = writeln!(out, "using Xunit;");
189    let _ = writeln!(out, "using {namespace};");
190    let _ = writeln!(out);
191    let _ = writeln!(out, "namespace Kreuzberg.E2e;");
192    let _ = writeln!(out);
193    let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
194    let _ = writeln!(out, "public class {test_class}");
195    let _ = writeln!(out, "{{");
196    // Shared options used when deserializing config JSON in test setup.
197    // Mirrors the options used by the library to ensure enum values round-trip correctly.
198    let _ = writeln!(
199        out,
200        "    private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
201    );
202    let _ = writeln!(out);
203
204    for (i, fixture) in fixtures.iter().enumerate() {
205        render_test_method(
206            &mut out,
207            fixture,
208            class_name,
209            function_name,
210            exception_class,
211            result_var,
212            args,
213            field_resolver,
214            result_is_simple,
215            is_async,
216            e2e_config,
217            enum_fields,
218        );
219        if i + 1 < fixtures.len() {
220            let _ = writeln!(out);
221        }
222    }
223
224    let _ = writeln!(out, "}}");
225    out
226}
227
228#[allow(clippy::too_many_arguments)]
229fn render_test_method(
230    out: &mut String,
231    fixture: &Fixture,
232    class_name: &str,
233    function_name: &str,
234    exception_class: &str,
235    result_var: &str,
236    args: &[crate::config::ArgMapping],
237    field_resolver: &FieldResolver,
238    result_is_simple: bool,
239    is_async: bool,
240    e2e_config: &E2eConfig,
241    enum_fields: &HashMap<String, String>,
242) {
243    let method_name = fixture.id.to_upper_camel_case();
244    let description = &fixture.description;
245    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
246
247    let (setup_lines, args_str) =
248        build_args_and_setup(&fixture.input, args, class_name, e2e_config, enum_fields, &fixture.id);
249
250    let return_type = if is_async { "async Task" } else { "void" };
251    let await_kw = if is_async { "await " } else { "" };
252
253    let _ = writeln!(out, "    [Fact]");
254    let _ = writeln!(out, "    public {return_type} Test_{method_name}()");
255    let _ = writeln!(out, "    {{");
256    let _ = writeln!(out, "        // {description}");
257
258    for line in &setup_lines {
259        let _ = writeln!(out, "        {line}");
260    }
261
262    if expects_error {
263        if is_async {
264            let _ = writeln!(
265                out,
266                "        await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({args_str}));"
267            );
268        } else {
269            let _ = writeln!(
270                out,
271                "        Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({args_str}));"
272            );
273        }
274        let _ = writeln!(out, "    }}");
275        return;
276    }
277
278    let _ = writeln!(
279        out,
280        "        var {result_var} = {await_kw}{class_name}.{function_name}({args_str});"
281    );
282
283    for assertion in &fixture.assertions {
284        render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
285    }
286
287    let _ = writeln!(out, "    }}");
288}
289
290/// Build setup lines (e.g. handle creation) and the argument list for the function call.
291///
292/// Returns `(setup_lines, args_string)`.
293fn build_args_and_setup(
294    input: &serde_json::Value,
295    args: &[crate::config::ArgMapping],
296    class_name: &str,
297    e2e_config: &E2eConfig,
298    enum_fields: &HashMap<String, String>,
299    fixture_id: &str,
300) -> (Vec<String>, String) {
301    if args.is_empty() {
302        return (Vec::new(), json_to_csharp(input));
303    }
304
305    let overrides = e2e_config.call.overrides.get("csharp");
306    let options_type = overrides.and_then(|o| o.options_type.as_deref());
307
308    let mut setup_lines: Vec<String> = Vec::new();
309    let mut parts: Vec<String> = Vec::new();
310
311    for arg in args {
312        if arg.arg_type == "mock_url" {
313            setup_lines.push(format!(
314                "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
315                arg.name,
316            ));
317            parts.push(arg.name.clone());
318            continue;
319        }
320
321        if arg.arg_type == "handle" {
322            // Generate a CreateEngine (or equivalent) call and pass the variable.
323            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
324            let config_value = input.get(&arg.field).unwrap_or(&serde_json::Value::Null);
325            if config_value.is_null()
326                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
327            {
328                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
329            } else {
330                // Sort discriminator fields ("type") to appear first in nested objects so
331                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
332                // reading other properties (a requirement as of .NET 8).
333                let sorted = sort_discriminator_first(config_value.clone());
334                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
335                let name = &arg.name;
336                setup_lines.push(format!(
337                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
338                    escape_csharp(&json_str),
339                ));
340                setup_lines.push(format!(
341                    "var {} = {class_name}.{constructor_name}({name}Config);",
342                    arg.name,
343                    name = name,
344                ));
345            }
346            parts.push(arg.name.clone());
347            continue;
348        }
349
350        let val = input.get(&arg.field);
351        match val {
352            None | Some(serde_json::Value::Null) if arg.optional => {
353                // Optional arg with no fixture value: pass null explicitly since
354                // C# nullable parameters still require an argument at the call site.
355                parts.push("null".to_string());
356                continue;
357            }
358            None | Some(serde_json::Value::Null) => {
359                // Required arg with no fixture value: pass a language-appropriate default.
360                let default_val = match arg.arg_type.as_str() {
361                    "string" => "\"\"".to_string(),
362                    "int" | "integer" => "0".to_string(),
363                    "float" | "number" => "0.0d".to_string(),
364                    "bool" | "boolean" => "false".to_string(),
365                    _ => "null".to_string(),
366                };
367                parts.push(default_val);
368            }
369            Some(v) => {
370                // For json_object args with options_type, construct a typed C# object.
371                if let (Some(opts_type), "json_object") = (options_type, arg.arg_type.as_str()) {
372                    if let Some(obj) = v.as_object() {
373                        let props: Vec<String> = obj
374                            .iter()
375                            .map(|(k, vv)| {
376                                let pascal_key = k.to_upper_camel_case();
377                                // Check if this field maps to an enum type.
378                                let cs_val = if let Some(enum_type) = enum_fields.get(k) {
379                                    // Map string value to enum constant (PascalCase).
380                                    if let Some(s) = vv.as_str() {
381                                        let pascal_val = s.to_upper_camel_case();
382                                        format!("{enum_type}.{pascal_val}")
383                                    } else {
384                                        json_to_csharp(vv)
385                                    }
386                                } else {
387                                    json_to_csharp(vv)
388                                };
389                                format!("{pascal_key} = {cs_val}")
390                            })
391                            .collect();
392                        parts.push(format!("new {opts_type} {{ {} }}", props.join(", ")));
393                        continue;
394                    }
395                }
396                parts.push(json_to_csharp(v));
397            }
398        }
399    }
400
401    (setup_lines, parts.join(", "))
402}
403
404fn render_assertion(
405    out: &mut String,
406    assertion: &Assertion,
407    result_var: &str,
408    field_resolver: &FieldResolver,
409    result_is_simple: bool,
410) {
411    // Skip assertions on fields that don't exist on the result type.
412    if let Some(f) = &assertion.field {
413        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
414            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
415            return;
416        }
417    }
418
419    let field_expr = if result_is_simple {
420        result_var.to_string()
421    } else {
422        match &assertion.field {
423            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", result_var),
424            _ => result_var.to_string(),
425        }
426    };
427
428    // Determine whether the field resolves to an optional (nullable) type in C#.
429    let field_is_optional = assertion
430        .field
431        .as_deref()
432        .map(|f| field_resolver.is_optional(field_resolver.resolve(f)))
433        .unwrap_or(false);
434
435    match assertion.assertion_type.as_str() {
436        "equals" => {
437            if let Some(expected) = &assertion.value {
438                let cs_val = json_to_csharp(expected);
439                // Only call .Trim() on string fields, not numeric or boolean ones.
440                if expected.is_string() {
441                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr}.Trim());");
442                } else if expected.is_number() && field_is_optional {
443                    // Nullable numeric fields require an explicit cast of the expected
444                    // literal so that C# can resolve the overload (e.g. ulong?).
445                    let _ = writeln!(out, "        Assert.Equal((object?){cs_val}, (object?){field_expr});");
446                } else {
447                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr});");
448                }
449            }
450        }
451        "contains" => {
452            if let Some(expected) = &assertion.value {
453                // Lowercase both expected and actual so that enum fields (where .ToString()
454                // returns the PascalCase C# member name like "Anchor") correctly match
455                // fixture snake_case values like "anchor".  String fields are unaffected
456                // because lowercasing both sides preserves substring matches.
457                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
458                let cs_val = lower_expected
459                    .as_deref()
460                    .map(|s| format!("\"{}\"", escape_csharp(s)))
461                    .unwrap_or_else(|| json_to_csharp(expected));
462                let _ = writeln!(
463                    out,
464                    "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
465                );
466            }
467        }
468        "contains_all" => {
469            if let Some(values) = &assertion.values {
470                for val in values {
471                    let lower_val = val.as_str().map(|s| s.to_lowercase());
472                    let cs_val = lower_val
473                        .as_deref()
474                        .map(|s| format!("\"{}\"", escape_csharp(s)))
475                        .unwrap_or_else(|| json_to_csharp(val));
476                    let _ = writeln!(
477                        out,
478                        "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
479                    );
480                }
481            }
482        }
483        "not_contains" => {
484            if let Some(expected) = &assertion.value {
485                let cs_val = json_to_csharp(expected);
486                let _ = writeln!(out, "        Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
487            }
488        }
489        "not_empty" => {
490            let _ = writeln!(
491                out,
492                "        Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
493            );
494        }
495        "is_empty" => {
496            let _ = writeln!(
497                out,
498                "        Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
499            );
500        }
501        "contains_any" => {
502            if let Some(values) = &assertion.values {
503                let checks: Vec<String> = values
504                    .iter()
505                    .map(|v| {
506                        let cs_val = json_to_csharp(v);
507                        format!("{field_expr}.ToString().Contains({cs_val})")
508                    })
509                    .collect();
510                let joined = checks.join(" || ");
511                let _ = writeln!(
512                    out,
513                    "        Assert.True({joined}, \"expected to contain at least one of the specified values\");"
514                );
515            }
516        }
517        "greater_than" => {
518            if let Some(val) = &assertion.value {
519                let cs_val = json_to_csharp(val);
520                let _ = writeln!(
521                    out,
522                    "        Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
523                );
524            }
525        }
526        "less_than" => {
527            if let Some(val) = &assertion.value {
528                let cs_val = json_to_csharp(val);
529                let _ = writeln!(
530                    out,
531                    "        Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
532                );
533            }
534        }
535        "greater_than_or_equal" => {
536            if let Some(val) = &assertion.value {
537                let cs_val = json_to_csharp(val);
538                let _ = writeln!(
539                    out,
540                    "        Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
541                );
542            }
543        }
544        "less_than_or_equal" => {
545            if let Some(val) = &assertion.value {
546                let cs_val = json_to_csharp(val);
547                let _ = writeln!(
548                    out,
549                    "        Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
550                );
551            }
552        }
553        "starts_with" => {
554            if let Some(expected) = &assertion.value {
555                let cs_val = json_to_csharp(expected);
556                let _ = writeln!(out, "        Assert.StartsWith({cs_val}, {field_expr});");
557            }
558        }
559        "ends_with" => {
560            if let Some(expected) = &assertion.value {
561                let cs_val = json_to_csharp(expected);
562                let _ = writeln!(out, "        Assert.EndsWith({cs_val}, {field_expr});");
563            }
564        }
565        "min_length" => {
566            if let Some(val) = &assertion.value {
567                if let Some(n) = val.as_u64() {
568                    let _ = writeln!(
569                        out,
570                        "        Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
571                    );
572                }
573            }
574        }
575        "max_length" => {
576            if let Some(val) = &assertion.value {
577                if let Some(n) = val.as_u64() {
578                    let _ = writeln!(
579                        out,
580                        "        Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
581                    );
582                }
583            }
584        }
585        "count_min" => {
586            if let Some(val) = &assertion.value {
587                if let Some(n) = val.as_u64() {
588                    let _ = writeln!(
589                        out,
590                        "        Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
591                    );
592                }
593            }
594        }
595        "not_error" => {
596            // Already handled by the call succeeding without exception.
597        }
598        "error" => {
599            // Handled at the test method level.
600        }
601        other => {
602            let _ = writeln!(out, "        // TODO: unsupported assertion type: {other}");
603        }
604    }
605}
606
607/// Recursively sort JSON objects so that any key named `"type"` appears first.
608///
609/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
610/// the first property when deserializing polymorphic types. Fixture config values
611/// serialised via serde_json preserve insertion/alphabetical order, which may put
612/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
613fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
614    match value {
615        serde_json::Value::Object(map) => {
616            let mut sorted = serde_json::Map::with_capacity(map.len());
617            // Insert "type" first if present.
618            if let Some(type_val) = map.get("type") {
619                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
620            }
621            for (k, v) in map {
622                if k != "type" {
623                    sorted.insert(k, sort_discriminator_first(v));
624                }
625            }
626            serde_json::Value::Object(sorted)
627        }
628        serde_json::Value::Array(arr) => {
629            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
630        }
631        other => other,
632    }
633}
634
635/// Convert a `serde_json::Value` to a C# literal string.
636fn json_to_csharp(value: &serde_json::Value) -> String {
637    match value {
638        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
639        serde_json::Value::Bool(true) => "true".to_string(),
640        serde_json::Value::Bool(false) => "false".to_string(),
641        serde_json::Value::Number(n) => {
642            if n.is_f64() {
643                format!("{}d", n)
644            } else {
645                n.to_string()
646            }
647        }
648        serde_json::Value::Null => "null".to_string(),
649        serde_json::Value::Array(arr) => {
650            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
651            format!("new[] {{ {} }}", items.join(", "))
652        }
653        serde_json::Value::Object(_) => {
654            let json_str = serde_json::to_string(value).unwrap_or_default();
655            format!("\"{}\"", escape_csharp(&json_str))
656        }
657    }
658}