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::fmt::Write as FmtWrite;
15use std::path::PathBuf;
16
17use super::E2eCodegen;
18
19pub struct CSharpCodegen;
21
22impl E2eCodegen for CSharpCodegen {
23 fn generate(
24 &self,
25 groups: &[FixtureGroup],
26 e2e_config: &E2eConfig,
27 alef_config: &AlefConfig,
28 ) -> Result<Vec<GeneratedFile>> {
29 let lang = self.language_name();
30 let output_base = PathBuf::from(&e2e_config.output).join(lang);
31
32 let mut files = Vec::new();
33
34 let call = &e2e_config.call;
36 let overrides = call.overrides.get(lang);
37 let function_name = overrides
38 .and_then(|o| o.function.as_ref())
39 .cloned()
40 .unwrap_or_else(|| call.function.to_upper_camel_case());
41 let class_name = overrides
42 .and_then(|o| o.class.as_ref())
43 .cloned()
44 .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
45 let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
46 if call.module.is_empty() {
47 "Kreuzberg".to_string()
48 } else {
49 call.module.to_upper_camel_case()
50 }
51 });
52 let result_var = &call.result_var;
53
54 let cs_pkg = e2e_config.packages.get("csharp");
56 let pkg_name = cs_pkg
57 .and_then(|p| p.name.as_ref())
58 .cloned()
59 .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
60 let pkg_path = cs_pkg
61 .and_then(|p| p.path.as_ref())
62 .cloned()
63 .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}.csproj"));
64
65 files.push(GeneratedFile {
67 path: output_base.join("E2eTests.csproj"),
68 content: render_csproj(&pkg_name, &pkg_path),
69 generated_header: false,
70 });
71
72 let tests_base = output_base.join("tests");
74 let field_resolver = FieldResolver::new(&e2e_config.fields, &e2e_config.fields_optional);
75
76 for group in groups {
77 let active: Vec<&Fixture> = group
78 .fixtures
79 .iter()
80 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
81 .collect();
82
83 if active.is_empty() {
84 continue;
85 }
86
87 let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
88 let filename = format!("{test_class}.cs");
89 let content = render_test_file(
90 &group.category,
91 &active,
92 &namespace,
93 &class_name,
94 &function_name,
95 result_var,
96 &test_class,
97 &e2e_config.call.args,
98 &field_resolver,
99 );
100 files.push(GeneratedFile {
101 path: tests_base.join(filename),
102 content,
103 generated_header: true,
104 });
105 }
106
107 Ok(files)
108 }
109
110 fn language_name(&self) -> &'static str {
111 "csharp"
112 }
113}
114
115fn render_csproj(_pkg_name: &str, pkg_path: &str) -> String {
120 format!(
121 r#"<Project Sdk="Microsoft.NET.Sdk">
122 <PropertyGroup>
123 <TargetFramework>net8.0</TargetFramework>
124 <Nullable>enable</Nullable>
125 <ImplicitUsings>enable</ImplicitUsings>
126 <IsPackable>false</IsPackable>
127 <IsTestProject>true</IsTestProject>
128 </PropertyGroup>
129
130 <ItemGroup>
131 <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
132 <PackageReference Include="xunit" Version="2.9.3" />
133 <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
134 </ItemGroup>
135
136 <ItemGroup>
137 <ProjectReference Include="{pkg_path}" />
138 </ItemGroup>
139</Project>
140"#
141 )
142}
143
144#[allow(clippy::too_many_arguments)]
145fn render_test_file(
146 category: &str,
147 fixtures: &[&Fixture],
148 namespace: &str,
149 class_name: &str,
150 function_name: &str,
151 result_var: &str,
152 test_class: &str,
153 args: &[crate::config::ArgMapping],
154 field_resolver: &FieldResolver,
155) -> String {
156 let mut out = String::new();
157 let _ = writeln!(out, "using Xunit;");
158 let _ = writeln!(out, "using {namespace};");
159 let _ = writeln!(out);
160 let _ = writeln!(out, "namespace Kreuzberg.E2e;");
161 let _ = writeln!(out);
162 let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
163 let _ = writeln!(out, "public class {test_class}");
164 let _ = writeln!(out, "{{");
165
166 for (i, fixture) in fixtures.iter().enumerate() {
167 render_test_method(
168 &mut out,
169 fixture,
170 class_name,
171 function_name,
172 result_var,
173 args,
174 field_resolver,
175 );
176 if i + 1 < fixtures.len() {
177 let _ = writeln!(out);
178 }
179 }
180
181 let _ = writeln!(out, "}}");
182 out
183}
184
185fn render_test_method(
186 out: &mut String,
187 fixture: &Fixture,
188 class_name: &str,
189 function_name: &str,
190 result_var: &str,
191 args: &[crate::config::ArgMapping],
192 field_resolver: &FieldResolver,
193) {
194 let method_name = fixture.id.to_upper_camel_case();
195 let description = &fixture.description;
196 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
197
198 let args_str = build_args_string(&fixture.input, args);
199
200 let _ = writeln!(out, " [Fact]");
201 let _ = writeln!(out, " public void Test_{method_name}()");
202 let _ = writeln!(out, " {{");
203 let _ = writeln!(out, " // {description}");
204
205 if expects_error {
206 let _ = writeln!(
207 out,
208 " Assert.Throws<Exception>(() => {class_name}.{function_name}({args_str}));"
209 );
210 let _ = writeln!(out, " }}");
211 return;
212 }
213
214 let _ = writeln!(
215 out,
216 " var {result_var} = {class_name}.{function_name}({args_str});"
217 );
218
219 for assertion in &fixture.assertions {
220 render_assertion(out, assertion, result_var, field_resolver);
221 }
222
223 let _ = writeln!(out, " }}");
224}
225
226fn build_args_string(input: &serde_json::Value, args: &[crate::config::ArgMapping]) -> String {
227 if args.is_empty() {
228 return json_to_csharp(input);
229 }
230
231 let parts: Vec<String> = args
232 .iter()
233 .filter_map(|arg| {
234 let val = input.get(&arg.field)?;
235 if val.is_null() && arg.optional {
236 return None;
237 }
238 Some(json_to_csharp(val))
239 })
240 .collect();
241
242 parts.join(", ")
243}
244
245fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
246 let field_expr = match &assertion.field {
247 Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", result_var),
248 _ => result_var.to_string(),
249 };
250
251 match assertion.assertion_type.as_str() {
252 "equals" => {
253 if let Some(expected) = &assertion.value {
254 let cs_val = json_to_csharp(expected);
255 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr}.Trim());");
256 }
257 }
258 "contains" => {
259 if let Some(expected) = &assertion.value {
260 let cs_val = json_to_csharp(expected);
261 let _ = writeln!(out, " Assert.Contains({cs_val}, {field_expr});");
262 }
263 }
264 "contains_all" => {
265 if let Some(values) = &assertion.values {
266 for val in values {
267 let cs_val = json_to_csharp(val);
268 let _ = writeln!(out, " Assert.Contains({cs_val}, {field_expr});");
269 }
270 }
271 }
272 "not_contains" => {
273 if let Some(expected) = &assertion.value {
274 let cs_val = json_to_csharp(expected);
275 let _ = writeln!(out, " Assert.DoesNotContain({cs_val}, {field_expr});");
276 }
277 }
278 "not_empty" => {
279 let _ = writeln!(out, " Assert.NotEmpty({field_expr});");
280 }
281 "is_empty" => {
282 let _ = writeln!(out, " Assert.Empty({field_expr});");
283 }
284 "starts_with" => {
285 if let Some(expected) = &assertion.value {
286 let cs_val = json_to_csharp(expected);
287 let _ = writeln!(out, " Assert.StartsWith({cs_val}, {field_expr});");
288 }
289 }
290 "ends_with" => {
291 if let Some(expected) = &assertion.value {
292 let cs_val = json_to_csharp(expected);
293 let _ = writeln!(out, " Assert.EndsWith({cs_val}, {field_expr});");
294 }
295 }
296 "min_length" => {
297 if let Some(val) = &assertion.value {
298 if let Some(n) = val.as_u64() {
299 let _ = writeln!(
300 out,
301 " Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
302 );
303 }
304 }
305 }
306 "max_length" => {
307 if let Some(val) = &assertion.value {
308 if let Some(n) = val.as_u64() {
309 let _ = writeln!(
310 out,
311 " Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
312 );
313 }
314 }
315 }
316 "not_error" => {
317 }
319 "error" => {
320 }
322 other => {
323 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
324 }
325 }
326}
327
328fn json_to_csharp(value: &serde_json::Value) -> String {
330 match value {
331 serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
332 serde_json::Value::Bool(true) => "true".to_string(),
333 serde_json::Value::Bool(false) => "false".to_string(),
334 serde_json::Value::Number(n) => {
335 if n.is_f64() {
336 format!("{}d", n)
337 } else {
338 n.to_string()
339 }
340 }
341 serde_json::Value::Null => "null".to_string(),
342 serde_json::Value::Array(arr) => {
343 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
344 format!("new[] {{ {} }}", items.join(", "))
345 }
346 serde_json::Value::Object(_) => {
347 let json_str = serde_json::to_string(value).unwrap_or_default();
348 format!("\"{}\"", escape_csharp(&json_str))
349 }
350 }
351}