1use crate::config::E2eConfig;
7use crate::escape::{escape_csharp, sanitize_filename};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, HttpFixture};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions as tv;
14use anyhow::Result;
15use heck::ToUpperCamelCase;
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21
22pub struct CSharpCodegen;
24
25impl E2eCodegen for CSharpCodegen {
26 fn generate(
27 &self,
28 groups: &[FixtureGroup],
29 e2e_config: &E2eConfig,
30 alef_config: &AlefConfig,
31 ) -> Result<Vec<GeneratedFile>> {
32 let lang = self.language_name();
33 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
34
35 let mut files = Vec::new();
36
37 let call = &e2e_config.call;
39 let overrides = call.overrides.get(lang);
40 let function_name = overrides
41 .and_then(|o| o.function.as_ref())
42 .cloned()
43 .unwrap_or_else(|| call.function.to_upper_camel_case());
44 let class_name = overrides
45 .and_then(|o| o.class.as_ref())
46 .cloned()
47 .unwrap_or_else(|| format!("{}Lib", alef_config.crate_config.name.to_upper_camel_case()));
48 let exception_class = format!("{}Exception", alef_config.crate_config.name.to_upper_camel_case());
50 let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
51 if call.module.is_empty() {
52 "Kreuzberg".to_string()
53 } else {
54 call.module.to_upper_camel_case()
55 }
56 });
57 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
58 let result_var = &call.result_var;
59 let is_async = call.r#async;
60
61 let cs_pkg = e2e_config.resolve_package("csharp");
63 let pkg_name = cs_pkg
64 .as_ref()
65 .and_then(|p| p.name.as_ref())
66 .cloned()
67 .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
68 let pkg_path = cs_pkg
71 .as_ref()
72 .and_then(|p| p.path.as_ref())
73 .cloned()
74 .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}.csproj"));
75 let pkg_version = cs_pkg
76 .as_ref()
77 .and_then(|p| p.version.as_ref())
78 .cloned()
79 .unwrap_or_else(|| "0.1.0".to_string());
80
81 let csproj_name = format!("{pkg_name}.E2eTests.csproj");
84 files.push(GeneratedFile {
85 path: output_base.join(&csproj_name),
86 content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
87 generated_header: false,
88 });
89
90 let tests_base = output_base.join("tests");
92 let field_resolver = FieldResolver::new(
93 &e2e_config.fields,
94 &e2e_config.fields_optional,
95 &e2e_config.result_fields,
96 &e2e_config.fields_array,
97 );
98
99 static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
101 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
102
103 for group in groups {
104 let active: Vec<&Fixture> = group
105 .fixtures
106 .iter()
107 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
108 .collect();
109
110 if active.is_empty() {
111 continue;
112 }
113
114 let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
115 let filename = format!("{test_class}.cs");
116 let content = render_test_file(
117 &group.category,
118 &active,
119 &namespace,
120 &class_name,
121 &function_name,
122 &exception_class,
123 result_var,
124 &test_class,
125 &e2e_config.call.args,
126 &field_resolver,
127 result_is_simple,
128 is_async,
129 e2e_config,
130 enum_fields,
131 );
132 files.push(GeneratedFile {
133 path: tests_base.join(filename),
134 content,
135 generated_header: true,
136 });
137 }
138
139 Ok(files)
140 }
141
142 fn language_name(&self) -> &'static str {
143 "csharp"
144 }
145}
146
147fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
152 let pkg_ref = match dep_mode {
153 crate::config::DependencyMode::Registry => {
154 format!(" <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
155 }
156 crate::config::DependencyMode::Local => {
157 format!(" <ProjectReference Include=\"{pkg_path}\" />")
158 }
159 };
160 format!(
161 r#"<Project Sdk="Microsoft.NET.Sdk">
162 <PropertyGroup>
163 <TargetFramework>net10.0</TargetFramework>
164 <Nullable>enable</Nullable>
165 <ImplicitUsings>enable</ImplicitUsings>
166 <IsPackable>false</IsPackable>
167 <IsTestProject>true</IsTestProject>
168 </PropertyGroup>
169
170 <ItemGroup>
171 <PackageReference Include="Microsoft.NET.Test.Sdk" Version="{ms_test_sdk}" />
172 <PackageReference Include="xunit" Version="{xunit}" />
173 <PackageReference Include="xunit.runner.visualstudio" Version="{xunit_runner}" />
174 </ItemGroup>
175
176 <ItemGroup>
177{pkg_ref}
178 </ItemGroup>
179</Project>
180"#,
181 ms_test_sdk = tv::nuget::MICROSOFT_NET_TEST_SDK,
182 xunit = tv::nuget::XUNIT,
183 xunit_runner = tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
184 )
185}
186
187#[allow(clippy::too_many_arguments)]
188fn render_test_file(
189 category: &str,
190 fixtures: &[&Fixture],
191 namespace: &str,
192 class_name: &str,
193 function_name: &str,
194 exception_class: &str,
195 result_var: &str,
196 test_class: &str,
197 args: &[crate::config::ArgMapping],
198 field_resolver: &FieldResolver,
199 result_is_simple: bool,
200 is_async: bool,
201 e2e_config: &E2eConfig,
202 enum_fields: &HashMap<String, String>,
203) -> String {
204 let mut out = String::new();
205 out.push_str(&hash::header(CommentStyle::DoubleSlash));
206 let _ = writeln!(out, "using System;");
208 let _ = writeln!(out, "using System.Collections.Generic;");
209 let _ = writeln!(out, "using System.Linq;");
210 let _ = writeln!(out, "using System.Net.Http;");
211 let _ = writeln!(out, "using System.Text;");
212 let _ = writeln!(out, "using System.Text.Json;");
213 let _ = writeln!(out, "using System.Text.Json.Serialization;");
214 let _ = writeln!(out, "using System.Threading.Tasks;");
215 let _ = writeln!(out, "using Xunit;");
216 let _ = writeln!(out, "using {namespace};");
217 let _ = writeln!(out);
218 let _ = writeln!(out, "namespace Kreuzberg.E2e;");
219 let _ = writeln!(out);
220 let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
221 let _ = writeln!(out, "public class {test_class}");
222 let _ = writeln!(out, "{{");
223 let _ = writeln!(
226 out,
227 " private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
228 );
229 let _ = writeln!(out);
230
231 for (i, fixture) in fixtures.iter().enumerate() {
232 render_test_method(
233 &mut out,
234 fixture,
235 class_name,
236 function_name,
237 exception_class,
238 result_var,
239 args,
240 field_resolver,
241 result_is_simple,
242 is_async,
243 e2e_config,
244 enum_fields,
245 );
246 if i + 1 < fixtures.len() {
247 let _ = writeln!(out);
248 }
249 }
250
251 let _ = writeln!(out, "}}");
252 out
253}
254
255fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
257 let method_name = fixture.id.to_upper_camel_case();
258 let description = &fixture.description;
259 let request = &http.request;
260 let expected = &http.expected_response;
261 let method = request.method.to_uppercase();
262 let fixture_id = &fixture.id;
263 let expected_status = expected.status_code;
264
265 let _ = writeln!(out, " [Fact]");
266 let _ = writeln!(out, " public async Task Test_{method_name}()");
267 let _ = writeln!(out, " {{");
268 let _ = writeln!(out, " // {description}");
269 let _ = writeln!(
270 out,
271 " var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";"
272 );
273 let _ = writeln!(out, " using var client = new System.Net.Http.HttpClient();");
274 let _ = writeln!(
275 out,
276 " var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}/fixtures/{fixture_id}\");"
277 );
278
279 let content_type = request.content_type.as_deref().unwrap_or("application/json");
281 if request.body.is_some() {
282 let json_str = serde_json::to_string(&request.body).unwrap_or_default();
283 let escaped = escape_csharp(&json_str);
284 let _ = writeln!(
285 out,
286 " request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");"
287 );
288 }
289
290 const CSHARP_RESTRICTED_HEADERS: &[&str] = &[
292 "content-length",
293 "host",
294 "connection",
295 "expect",
296 "transfer-encoding",
297 "upgrade",
298 ];
299
300 for (name, value) in &request.headers {
301 if CSHARP_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
302 continue;
303 }
304 let escaped_name = escape_csharp(name);
305 let escaped_value = escape_csharp(value);
306 let _ = writeln!(
307 out,
308 " request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");"
309 );
310 }
311
312 if !request.cookies.is_empty() {
314 let cookie_str: Vec<String> = request.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
315 let cookie_header = escape_csharp(&cookie_str.join("; "));
316 let _ = writeln!(out, " request.Headers.Add(\"Cookie\", \"{cookie_header}\");");
317 }
318
319 let _ = writeln!(out, " var response = await client.SendAsync(request);");
320 let _ = writeln!(
321 out,
322 " Assert.Equal({expected_status}, (int)response.StatusCode);"
323 );
324
325 if let Some(expected_body) = &expected.body {
327 match expected_body {
328 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
329 let json_str = serde_json::to_string(expected_body).unwrap_or_default();
330 let escaped = escape_csharp(&json_str);
331 let _ = writeln!(
332 out,
333 " var bodyText = await response.Content.ReadAsStringAsync();"
334 );
335 let _ = writeln!(out, " var body = JsonDocument.Parse(bodyText).RootElement;");
336 let _ = writeln!(
337 out,
338 " var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;"
339 );
340 let _ = writeln!(
341 out,
342 " Assert.Equal(expectedBody.GetRawText(), body.GetRawText());"
343 );
344 }
345 serde_json::Value::String(s) => {
346 let escaped = escape_csharp(s);
347 let _ = writeln!(
348 out,
349 " var bodyText = await response.Content.ReadAsStringAsync();"
350 );
351 let _ = writeln!(out, " Assert.Equal(\"{escaped}\", bodyText.Trim());");
352 }
353 other => {
354 let escaped = escape_csharp(&other.to_string());
355 let _ = writeln!(
356 out,
357 " var bodyText = await response.Content.ReadAsStringAsync();"
358 );
359 let _ = writeln!(out, " Assert.Equal(\"{escaped}\", bodyText.Trim());");
360 }
361 }
362 }
363
364 for (name, value) in &expected.headers {
366 if value == "<<absent>>" || value == "<<present>>" || value == "<<uuid>>" {
367 continue;
368 }
369 if name.to_lowercase() == "content-encoding" {
371 continue;
372 }
373 let escaped_name = escape_csharp(name);
374 let escaped_value = escape_csharp(value);
375 let _ = writeln!(
376 out,
377 " Assert.True(response.Headers.TryGetValues(\"{escaped_name}\", out var values) && values.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");"
378 );
379 }
380
381 let _ = writeln!(out, " }}");
382}
383
384#[allow(clippy::too_many_arguments)]
385fn render_test_method(
386 out: &mut String,
387 fixture: &Fixture,
388 class_name: &str,
389 _function_name: &str,
390 exception_class: &str,
391 _result_var: &str,
392 _args: &[crate::config::ArgMapping],
393 field_resolver: &FieldResolver,
394 result_is_simple: bool,
395 _is_async: bool,
396 e2e_config: &E2eConfig,
397 enum_fields: &HashMap<String, String>,
398) {
399 let method_name = fixture.id.to_upper_camel_case();
400 let description = &fixture.description;
401
402 if let Some(http) = &fixture.http {
404 render_http_test_method(out, fixture, http);
405 return;
406 }
407
408 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
409
410 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
413 let lang = "csharp";
414 let cs_overrides = call_config.overrides.get(lang);
415 let effective_function_name = cs_overrides
416 .and_then(|o| o.function.as_ref())
417 .cloned()
418 .unwrap_or_else(|| call_config.function.to_upper_camel_case());
419 let effective_result_var = &call_config.result_var;
420 let effective_is_async = call_config.r#async;
421 let function_name = effective_function_name.as_str();
422 let result_var = effective_result_var.as_str();
423 let is_async = effective_is_async;
424 let args = call_config.args.as_slice();
425
426 let per_call_result_is_simple = cs_overrides.is_some_and(|o| o.result_is_simple);
428 let effective_result_is_simple = result_is_simple || per_call_result_is_simple;
429 let returns_void = call_config.returns_void;
430 let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
431 let top_level_options_type = e2e_config
433 .call
434 .overrides
435 .get("csharp")
436 .and_then(|o| o.options_type.as_deref());
437 let effective_options_type = cs_overrides
438 .and_then(|o| o.options_type.as_deref())
439 .or(top_level_options_type);
440
441 let (mut setup_lines, args_str) = build_args_and_setup(
442 &fixture.input,
443 args,
444 class_name,
445 effective_options_type,
446 enum_fields,
447 &fixture.id,
448 );
449
450 let mut visitor_arg = String::new();
452 if let Some(visitor_spec) = &fixture.visitor {
453 visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_spec);
454 }
455
456 let args_with_visitor = if visitor_arg.is_empty() {
457 args_str
458 } else {
459 format!("{args_str}, {visitor_arg}")
460 };
461
462 let final_args = if extra_args_slice.is_empty() {
463 args_with_visitor
464 } else if args_with_visitor.is_empty() {
465 extra_args_slice.join(", ")
466 } else {
467 format!("{args_with_visitor}, {}", extra_args_slice.join(", "))
468 };
469
470 let return_type = if is_async { "async Task" } else { "void" };
471 let await_kw = if is_async { "await " } else { "" };
472
473 let _ = writeln!(out, " [Fact]");
474 let _ = writeln!(out, " public {return_type} Test_{method_name}()");
475 let _ = writeln!(out, " {{");
476 let _ = writeln!(out, " // {description}");
477
478 for line in &setup_lines {
479 let _ = writeln!(out, " {line}");
480 }
481
482 if expects_error {
483 if is_async {
484 let _ = writeln!(
485 out,
486 " await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
487 );
488 } else {
489 let _ = writeln!(
490 out,
491 " Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
492 );
493 }
494 let _ = writeln!(out, " }}");
495 return;
496 }
497
498 let result_is_vec = cs_overrides.is_some_and(|o| o.result_is_vec);
499
500 if returns_void {
501 let _ = writeln!(out, " {await_kw}{class_name}.{function_name}({final_args});");
502 } else {
503 let _ = writeln!(
504 out,
505 " var {result_var} = {await_kw}{class_name}.{function_name}({final_args});"
506 );
507 for assertion in &fixture.assertions {
508 render_assertion(
509 out,
510 assertion,
511 result_var,
512 class_name,
513 exception_class,
514 field_resolver,
515 effective_result_is_simple,
516 result_is_vec,
517 );
518 }
519 }
520
521 let _ = writeln!(out, " }}");
522}
523
524fn build_args_and_setup(
528 input: &serde_json::Value,
529 args: &[crate::config::ArgMapping],
530 class_name: &str,
531 options_type: Option<&str>,
532 _enum_fields: &HashMap<String, String>,
533 fixture_id: &str,
534) -> (Vec<String>, String) {
535 if args.is_empty() {
536 return (Vec::new(), String::new());
537 }
538
539 let mut setup_lines: Vec<String> = Vec::new();
540 let mut parts: Vec<String> = Vec::new();
541
542 for arg in args {
543 if arg.arg_type == "bytes" {
544 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
547 let val = input.get(field);
548 match val {
549 None | Some(serde_json::Value::Null) if arg.optional => {
550 parts.push("null".to_string());
551 }
552 None | Some(serde_json::Value::Null) => {
553 parts.push("System.Array.Empty<byte>()".to_string());
554 }
555 Some(v) => {
556 let cs_str = json_to_csharp(v);
557 parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
558 }
559 }
560 continue;
561 }
562
563 if arg.arg_type == "mock_url" {
564 setup_lines.push(format!(
565 "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
566 arg.name,
567 ));
568 parts.push(arg.name.clone());
569 continue;
570 }
571
572 if arg.arg_type == "handle" {
573 let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
575 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
576 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
577 if config_value.is_null()
578 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
579 {
580 setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
581 } else {
582 let sorted = sort_discriminator_first(config_value.clone());
586 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
587 let name = &arg.name;
588 setup_lines.push(format!(
589 "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
590 escape_csharp(&json_str),
591 ));
592 setup_lines.push(format!(
593 "var {} = {class_name}.{constructor_name}({name}Config);",
594 arg.name,
595 name = name,
596 ));
597 }
598 parts.push(arg.name.clone());
599 continue;
600 }
601
602 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
603 let val = input.get(field);
604 match val {
605 None | Some(serde_json::Value::Null) if arg.optional => {
606 parts.push("null".to_string());
609 continue;
610 }
611 None | Some(serde_json::Value::Null) => {
612 let default_val = match arg.arg_type.as_str() {
614 "string" => "\"\"".to_string(),
615 "int" | "integer" => "0".to_string(),
616 "float" | "number" => "0.0d".to_string(),
617 "bool" | "boolean" => "false".to_string(),
618 _ => "null".to_string(),
619 };
620 parts.push(default_val);
621 }
622 Some(v) => {
623 if arg.arg_type == "json_object" {
624 if let Some(arr) = v.as_array() {
626 parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
627 continue;
628 }
629 if let Some(opts_type) = options_type {
632 if v.is_object() {
633 let json_str = serde_json::to_string(v).unwrap_or_default();
634 parts.push(format!(
635 "JsonSerializer.Deserialize<{opts_type}>(\"{}\", ConfigOptions)!",
636 escape_csharp(&json_str),
637 ));
638 continue;
639 }
640 }
641 }
642 parts.push(json_to_csharp(v));
643 }
644 }
645 }
646
647 (setup_lines, parts.join(", "))
648}
649
650fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
657 match element_type {
658 Some("f32") => {
659 let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
660 format!("new List<float>() {{ {} }}", items.join(", "))
661 }
662 Some("(String, String)") => {
663 let items: Vec<String> = arr
664 .iter()
665 .map(|v| {
666 let strs: Vec<String> = v
667 .as_array()
668 .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
669 format!("new List<string>() {{ {} }}", strs.join(", "))
670 })
671 .collect();
672 format!("new List<List<string>>() {{ {} }}", items.join(", "))
673 }
674 _ => {
675 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
676 format!("new List<string>() {{ {} }}", items.join(", "))
677 }
678 }
679}
680
681#[allow(clippy::too_many_arguments)]
682fn render_assertion(
683 out: &mut String,
684 assertion: &Assertion,
685 result_var: &str,
686 class_name: &str,
687 exception_class: &str,
688 field_resolver: &FieldResolver,
689 result_is_simple: bool,
690 result_is_vec: bool,
691) {
692 if let Some(f) = &assertion.field {
695 match f.as_str() {
696 "chunks_have_content" => {
697 let pred = format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
698 match assertion.assertion_type.as_str() {
699 "is_true" => {
700 let _ = writeln!(out, " Assert.True({pred});");
701 }
702 "is_false" => {
703 let _ = writeln!(out, " Assert.False({pred});");
704 }
705 _ => {
706 let _ = writeln!(
707 out,
708 " // skipped: unsupported assertion type on synthetic field '{f}'"
709 );
710 }
711 }
712 return;
713 }
714 "chunks_have_embeddings" => {
715 let pred =
716 format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
717 match assertion.assertion_type.as_str() {
718 "is_true" => {
719 let _ = writeln!(out, " Assert.True({pred});");
720 }
721 "is_false" => {
722 let _ = writeln!(out, " Assert.False({pred});");
723 }
724 _ => {
725 let _ = writeln!(
726 out,
727 " // skipped: unsupported assertion type on synthetic field '{f}'"
728 );
729 }
730 }
731 return;
732 }
733 "embeddings" => {
737 match assertion.assertion_type.as_str() {
738 "count_equals" => {
739 if let Some(val) = &assertion.value {
740 let cs_val = json_to_csharp(val);
741 let _ = writeln!(out, " Assert.True({result_var}.Count == {cs_val});");
742 }
743 }
744 "count_min" => {
745 if let Some(val) = &assertion.value {
746 let cs_val = json_to_csharp(val);
747 let _ = writeln!(out, " Assert.True({result_var}.Count >= {cs_val});");
748 }
749 }
750 "not_empty" => {
751 let _ = writeln!(out, " Assert.NotEmpty({result_var});");
752 }
753 "is_empty" => {
754 let _ = writeln!(out, " Assert.Empty({result_var});");
755 }
756 _ => {
757 let _ = writeln!(
758 out,
759 " // skipped: unsupported assertion type on synthetic field 'embeddings'"
760 );
761 }
762 }
763 return;
764 }
765 "embedding_dimensions" => {
766 let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
767 match assertion.assertion_type.as_str() {
768 "equals" => {
769 if let Some(val) = &assertion.value {
770 let cs_val = json_to_csharp(val);
771 let _ = writeln!(out, " Assert.True({expr} == {cs_val});");
772 }
773 }
774 "greater_than" => {
775 if let Some(val) = &assertion.value {
776 let cs_val = json_to_csharp(val);
777 let _ = writeln!(out, " Assert.True({expr} > {cs_val});");
778 }
779 }
780 _ => {
781 let _ = writeln!(
782 out,
783 " // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
784 );
785 }
786 }
787 return;
788 }
789 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
790 let pred = match f.as_str() {
791 "embeddings_valid" => {
792 format!("{result_var}.All(e => e.Count > 0)")
793 }
794 "embeddings_finite" => {
795 format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
796 }
797 "embeddings_non_zero" => {
798 format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
799 }
800 "embeddings_normalized" => {
801 format!(
802 "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
803 )
804 }
805 _ => unreachable!(),
806 };
807 match assertion.assertion_type.as_str() {
808 "is_true" => {
809 let _ = writeln!(out, " Assert.True({pred});");
810 }
811 "is_false" => {
812 let _ = writeln!(out, " Assert.False({pred});");
813 }
814 _ => {
815 let _ = writeln!(
816 out,
817 " // skipped: unsupported assertion type on synthetic field '{f}'"
818 );
819 }
820 }
821 return;
822 }
823 "keywords" | "keywords_count" => {
826 let _ = writeln!(
827 out,
828 " // skipped: field '{f}' not available on C# ExtractionResult"
829 );
830 return;
831 }
832 _ => {}
833 }
834 }
835
836 if let Some(f) = &assertion.field {
838 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
839 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
840 return;
841 }
842 }
843
844 let effective_result_var: String = if result_is_vec {
846 format!("{result_var}[0]")
847 } else {
848 result_var.to_string()
849 };
850
851 let field_expr = if result_is_simple {
852 effective_result_var.clone()
853 } else {
854 match &assertion.field {
855 Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
856 _ => effective_result_var.clone(),
857 }
858 };
859
860 match assertion.assertion_type.as_str() {
861 "equals" => {
862 if let Some(expected) = &assertion.value {
863 let cs_val = json_to_csharp(expected);
864 if expected.is_string() {
865 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr}.Trim());");
867 } else if expected.as_bool() == Some(true) {
868 let _ = writeln!(out, " Assert.True({field_expr});");
870 } else if expected.as_bool() == Some(false) {
871 let _ = writeln!(out, " Assert.False({field_expr});");
873 } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
874 let _ = writeln!(out, " Assert.True({field_expr} == {cs_val});");
877 } else {
878 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr});");
879 }
880 }
881 }
882 "contains" => {
883 if let Some(expected) = &assertion.value {
884 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
889 let cs_val = lower_expected
890 .as_deref()
891 .map(|s| format!("\"{}\"", escape_csharp(s)))
892 .unwrap_or_else(|| json_to_csharp(expected));
893 let _ = writeln!(
894 out,
895 " Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
896 );
897 }
898 }
899 "contains_all" => {
900 if let Some(values) = &assertion.values {
901 for val in values {
902 let lower_val = val.as_str().map(|s| s.to_lowercase());
903 let cs_val = lower_val
904 .as_deref()
905 .map(|s| format!("\"{}\"", escape_csharp(s)))
906 .unwrap_or_else(|| json_to_csharp(val));
907 let _ = writeln!(
908 out,
909 " Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
910 );
911 }
912 }
913 }
914 "not_contains" => {
915 if let Some(expected) = &assertion.value {
916 let cs_val = json_to_csharp(expected);
917 let _ = writeln!(out, " Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
918 }
919 }
920 "not_empty" => {
921 let _ = writeln!(
922 out,
923 " Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
924 );
925 }
926 "is_empty" => {
927 let _ = writeln!(
928 out,
929 " Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
930 );
931 }
932 "contains_any" => {
933 if let Some(values) = &assertion.values {
934 let checks: Vec<String> = values
935 .iter()
936 .map(|v| {
937 let cs_val = json_to_csharp(v);
938 format!("{field_expr}.ToString().Contains({cs_val})")
939 })
940 .collect();
941 let joined = checks.join(" || ");
942 let _ = writeln!(
943 out,
944 " Assert.True({joined}, \"expected to contain at least one of the specified values\");"
945 );
946 }
947 }
948 "greater_than" => {
949 if let Some(val) = &assertion.value {
950 let cs_val = json_to_csharp(val);
951 let _ = writeln!(
952 out,
953 " Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
954 );
955 }
956 }
957 "less_than" => {
958 if let Some(val) = &assertion.value {
959 let cs_val = json_to_csharp(val);
960 let _ = writeln!(
961 out,
962 " Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
963 );
964 }
965 }
966 "greater_than_or_equal" => {
967 if let Some(val) = &assertion.value {
968 let cs_val = json_to_csharp(val);
969 let _ = writeln!(
970 out,
971 " Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
972 );
973 }
974 }
975 "less_than_or_equal" => {
976 if let Some(val) = &assertion.value {
977 let cs_val = json_to_csharp(val);
978 let _ = writeln!(
979 out,
980 " Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
981 );
982 }
983 }
984 "starts_with" => {
985 if let Some(expected) = &assertion.value {
986 let cs_val = json_to_csharp(expected);
987 let _ = writeln!(out, " Assert.StartsWith({cs_val}, {field_expr});");
988 }
989 }
990 "ends_with" => {
991 if let Some(expected) = &assertion.value {
992 let cs_val = json_to_csharp(expected);
993 let _ = writeln!(out, " Assert.EndsWith({cs_val}, {field_expr});");
994 }
995 }
996 "min_length" => {
997 if let Some(val) = &assertion.value {
998 if let Some(n) = val.as_u64() {
999 let _ = writeln!(
1000 out,
1001 " Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
1002 );
1003 }
1004 }
1005 }
1006 "max_length" => {
1007 if let Some(val) = &assertion.value {
1008 if let Some(n) = val.as_u64() {
1009 let _ = writeln!(
1010 out,
1011 " Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
1012 );
1013 }
1014 }
1015 }
1016 "count_min" => {
1017 if let Some(val) = &assertion.value {
1018 if let Some(n) = val.as_u64() {
1019 let _ = writeln!(
1020 out,
1021 " Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
1022 );
1023 }
1024 }
1025 }
1026 "count_equals" => {
1027 if let Some(val) = &assertion.value {
1028 if let Some(n) = val.as_u64() {
1029 let _ = writeln!(out, " Assert.Equal({n}, {field_expr}.Count);");
1030 }
1031 }
1032 }
1033 "is_true" => {
1034 let _ = writeln!(out, " Assert.True({field_expr});");
1035 }
1036 "is_false" => {
1037 let _ = writeln!(out, " Assert.False({field_expr});");
1038 }
1039 "not_error" => {
1040 }
1042 "error" => {
1043 }
1045 "method_result" => {
1046 if let Some(method_name) = &assertion.method {
1047 let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
1048 let check = assertion.check.as_deref().unwrap_or("is_true");
1049 match check {
1050 "equals" => {
1051 if let Some(val) = &assertion.value {
1052 if val.as_bool() == Some(true) {
1053 let _ = writeln!(out, " Assert.True({call_expr});");
1054 } else if val.as_bool() == Some(false) {
1055 let _ = writeln!(out, " Assert.False({call_expr});");
1056 } else {
1057 let cs_val = json_to_csharp(val);
1058 let _ = writeln!(out, " Assert.Equal({cs_val}, {call_expr});");
1059 }
1060 }
1061 }
1062 "is_true" => {
1063 let _ = writeln!(out, " Assert.True({call_expr});");
1064 }
1065 "is_false" => {
1066 let _ = writeln!(out, " Assert.False({call_expr});");
1067 }
1068 "greater_than_or_equal" => {
1069 if let Some(val) = &assertion.value {
1070 let n = val.as_u64().unwrap_or(0);
1071 let _ = writeln!(out, " Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
1072 }
1073 }
1074 "count_min" => {
1075 if let Some(val) = &assertion.value {
1076 let n = val.as_u64().unwrap_or(0);
1077 let _ = writeln!(
1078 out,
1079 " Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
1080 );
1081 }
1082 }
1083 "is_error" => {
1084 let _ = writeln!(
1085 out,
1086 " Assert.Throws<{exception_class}>(() => {{ {call_expr}; }});"
1087 );
1088 }
1089 "contains" => {
1090 if let Some(val) = &assertion.value {
1091 let cs_val = json_to_csharp(val);
1092 let _ = writeln!(out, " Assert.Contains({cs_val}, {call_expr});");
1093 }
1094 }
1095 other_check => {
1096 panic!("C# e2e generator: unsupported method_result check type: {other_check}");
1097 }
1098 }
1099 } else {
1100 panic!("C# e2e generator: method_result assertion missing 'method' field");
1101 }
1102 }
1103 "matches_regex" => {
1104 if let Some(expected) = &assertion.value {
1105 let cs_val = json_to_csharp(expected);
1106 let _ = writeln!(out, " Assert.Matches({cs_val}, {field_expr});");
1107 }
1108 }
1109 other => {
1110 panic!("C# e2e generator: unsupported assertion type: {other}");
1111 }
1112 }
1113}
1114
1115fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
1122 match value {
1123 serde_json::Value::Object(map) => {
1124 let mut sorted = serde_json::Map::with_capacity(map.len());
1125 if let Some(type_val) = map.get("type") {
1127 sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
1128 }
1129 for (k, v) in map {
1130 if k != "type" {
1131 sorted.insert(k, sort_discriminator_first(v));
1132 }
1133 }
1134 serde_json::Value::Object(sorted)
1135 }
1136 serde_json::Value::Array(arr) => {
1137 serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
1138 }
1139 other => other,
1140 }
1141}
1142
1143fn json_to_csharp(value: &serde_json::Value) -> String {
1145 match value {
1146 serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
1147 serde_json::Value::Bool(true) => "true".to_string(),
1148 serde_json::Value::Bool(false) => "false".to_string(),
1149 serde_json::Value::Number(n) => {
1150 if n.is_f64() {
1151 format!("{}d", n)
1152 } else {
1153 n.to_string()
1154 }
1155 }
1156 serde_json::Value::Null => "null".to_string(),
1157 serde_json::Value::Array(arr) => {
1158 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1159 format!("new[] {{ {} }}", items.join(", "))
1160 }
1161 serde_json::Value::Object(_) => {
1162 let json_str = serde_json::to_string(value).unwrap_or_default();
1163 format!("\"{}\"", escape_csharp(&json_str))
1164 }
1165 }
1166}
1167
1168fn build_csharp_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1174 setup_lines.push("var _testVisitor = new TestVisitor();".to_string());
1175 setup_lines.push("class TestVisitor : IVisitor".to_string());
1176 setup_lines.push("{".to_string());
1177 for (method_name, action) in &visitor_spec.callbacks {
1178 emit_csharp_visitor_method(setup_lines, method_name, action);
1179 }
1180 setup_lines.push("}".to_string());
1181 "_testVisitor".to_string()
1182}
1183
1184fn emit_csharp_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1186 let camel_method = method_to_camel(method_name);
1187 let params = match method_name {
1188 "visit_link" => "VisitContext ctx, string href, string text, string title",
1189 "visit_image" => "VisitContext ctx, string src, string alt, string title",
1190 "visit_heading" => "VisitContext ctx, int level, string text, string id",
1191 "visit_code_block" => "VisitContext ctx, string lang, string code",
1192 "visit_code_inline"
1193 | "visit_strong"
1194 | "visit_emphasis"
1195 | "visit_strikethrough"
1196 | "visit_underline"
1197 | "visit_subscript"
1198 | "visit_superscript"
1199 | "visit_mark"
1200 | "visit_button"
1201 | "visit_summary"
1202 | "visit_figcaption"
1203 | "visit_definition_term"
1204 | "visit_definition_description" => "VisitContext ctx, string text",
1205 "visit_text" => "VisitContext ctx, string text",
1206 "visit_list_item" => "VisitContext ctx, bool ordered, string marker, string text",
1207 "visit_blockquote" => "VisitContext ctx, string content, int depth",
1208 "visit_table_row" => "VisitContext ctx, IReadOnlyList<string> cells, bool isHeader",
1209 "visit_custom_element" => "VisitContext ctx, string tagName, string html",
1210 "visit_form" => "VisitContext ctx, string actionUrl, string method",
1211 "visit_input" => "VisitContext ctx, string inputType, string name, string value",
1212 "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, string src",
1213 "visit_details" => "VisitContext ctx, bool isOpen",
1214 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1215 "VisitContext ctx, string output"
1216 }
1217 "visit_list_start" => "VisitContext ctx, bool ordered",
1218 "visit_list_end" => "VisitContext ctx, bool ordered, string output",
1219 _ => "VisitContext ctx",
1220 };
1221
1222 setup_lines.push(format!(" public VisitResult {camel_method}({params})"));
1223 setup_lines.push(" {".to_string());
1224 match action {
1225 CallbackAction::Skip => {
1226 setup_lines.push(" return VisitResult.Skip();".to_string());
1227 }
1228 CallbackAction::Continue => {
1229 setup_lines.push(" return VisitResult.Continue();".to_string());
1230 }
1231 CallbackAction::PreserveHtml => {
1232 setup_lines.push(" return VisitResult.PreserveHtml();".to_string());
1233 }
1234 CallbackAction::Custom { output } => {
1235 let escaped = escape_csharp(output);
1236 setup_lines.push(format!(" return VisitResult.Custom(\"{escaped}\");"));
1237 }
1238 CallbackAction::CustomTemplate { template } => {
1239 let escaped = escape_csharp(template);
1240 setup_lines.push(format!(" return VisitResult.Custom($\"{escaped}\");"));
1241 }
1242 }
1243 setup_lines.push(" }".to_string());
1244}
1245
1246fn method_to_camel(snake: &str) -> String {
1248 use heck::ToUpperCamelCase;
1249 snake.to_upper_camel_case()
1250}
1251
1252fn build_csharp_method_call(
1257 result_var: &str,
1258 method_name: &str,
1259 args: Option<&serde_json::Value>,
1260 class_name: &str,
1261) -> String {
1262 match method_name {
1263 "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
1264 "root_node_type" => format!("{result_var}.RootNode.Kind"),
1265 "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
1266 "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
1267 "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
1268 "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
1269 "contains_node_type" => {
1270 let node_type = args
1271 .and_then(|a| a.get("node_type"))
1272 .and_then(|v| v.as_str())
1273 .unwrap_or("");
1274 format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
1275 }
1276 "find_nodes_by_type" => {
1277 let node_type = args
1278 .and_then(|a| a.get("node_type"))
1279 .and_then(|v| v.as_str())
1280 .unwrap_or("");
1281 format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
1282 }
1283 "run_query" => {
1284 let query_source = args
1285 .and_then(|a| a.get("query_source"))
1286 .and_then(|v| v.as_str())
1287 .unwrap_or("");
1288 let language = args
1289 .and_then(|a| a.get("language"))
1290 .and_then(|v| v.as_str())
1291 .unwrap_or("");
1292 format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1293 }
1294 _ => {
1295 use heck::ToUpperCamelCase;
1296 let pascal = method_name.to_upper_camel_case();
1297 format!("{result_var}.{pascal}()")
1298 }
1299 }
1300}