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 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 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 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 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 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 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
133fn 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 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
285fn 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 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 parts.push("null".to_string());
347 continue;
348 }
349 None | Some(serde_json::Value::Null) => {
350 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 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 let cs_val = if let Some(enum_type) = enum_fields.get(k) {
370 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 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 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 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 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 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 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 }
568 "error" => {
569 }
571 other => {
572 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
573 }
574 }
575}
576
577fn 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}