1use 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
20pub 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 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 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 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 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 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 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 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
136fn 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 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 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
290fn 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 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 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 parts.push("null".to_string());
356 continue;
357 }
358 None | Some(serde_json::Value::Null) => {
359 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 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 let cs_val = if let Some(enum_type) = enum_fields.get(k) {
379 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 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 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 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 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 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 }
598 "error" => {
599 }
601 other => {
602 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
603 }
604 }
605}
606
607fn 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 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
635fn 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}