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