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 = {
263 let lower = request.method.to_ascii_lowercase();
264 let mut chars = lower.chars();
265 match chars.next() {
266 Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
267 None => String::new(),
268 }
269 };
270 let fixture_id = &fixture.id;
271 let expected_status = expected.status_code;
272
273 let _ = writeln!(out, " [Fact]");
274 let _ = writeln!(out, " public async Task Test_{method_name}()");
275 let _ = writeln!(out, " {{");
276 let _ = writeln!(out, " // {description}");
277 let _ = writeln!(
278 out,
279 " var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";"
280 );
281 let _ = writeln!(
284 out,
285 " using var handler = new System.Net.Http.HttpClientHandler {{ AllowAutoRedirect = false }};"
286 );
287 let _ = writeln!(
288 out,
289 " using var client = new System.Net.Http.HttpClient(handler);"
290 );
291 let _ = writeln!(
292 out,
293 " var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}/fixtures/{fixture_id}\");"
294 );
295
296 let content_type = request.content_type.as_deref().unwrap_or("application/json");
298 if request.body.is_some() {
299 let json_str = serde_json::to_string(&request.body).unwrap_or_default();
300 let escaped = escape_csharp(&json_str);
301 let _ = writeln!(
302 out,
303 " request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");"
304 );
305 }
306
307 const CSHARP_RESTRICTED_HEADERS: &[&str] = &[
309 "content-length",
310 "host",
311 "connection",
312 "expect",
313 "transfer-encoding",
314 "upgrade",
315 "content-type",
318 "content-encoding",
320 "content-language",
321 "content-location",
322 "content-md5",
323 "content-range",
324 "content-disposition",
325 ];
326
327 for (name, value) in &request.headers {
328 if CSHARP_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
329 continue;
330 }
331 let escaped_name = escape_csharp(name);
332 let escaped_value = escape_csharp(value);
333 let _ = writeln!(
334 out,
335 " request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");"
336 );
337 }
338
339 if !request.cookies.is_empty() {
341 let cookie_str: Vec<String> = request.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
342 let cookie_header = escape_csharp(&cookie_str.join("; "));
343 let _ = writeln!(out, " request.Headers.Add(\"Cookie\", \"{cookie_header}\");");
344 }
345
346 let _ = writeln!(out, " var response = await client.SendAsync(request);");
347 let _ = writeln!(
348 out,
349 " Assert.Equal({expected_status}, (int)response.StatusCode);"
350 );
351
352 if let Some(expected_body) = &expected.body {
354 match expected_body {
355 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
356 let json_str = serde_json::to_string(expected_body).unwrap_or_default();
357 let escaped = escape_csharp(&json_str);
358 let _ = writeln!(
359 out,
360 " var bodyText = await response.Content.ReadAsStringAsync();"
361 );
362 let _ = writeln!(out, " var body = JsonDocument.Parse(bodyText).RootElement;");
363 let _ = writeln!(
364 out,
365 " var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;"
366 );
367 let _ = writeln!(
368 out,
369 " Assert.Equal(expectedBody.GetRawText(), body.GetRawText());"
370 );
371 }
372 serde_json::Value::String(s) => {
373 let escaped = escape_csharp(s);
374 let _ = writeln!(
375 out,
376 " var bodyText = await response.Content.ReadAsStringAsync();"
377 );
378 let _ = writeln!(out, " Assert.Equal(\"{escaped}\", bodyText.Trim());");
379 }
380 other => {
381 let escaped = escape_csharp(&other.to_string());
382 let _ = writeln!(
383 out,
384 " var bodyText = await response.Content.ReadAsStringAsync();"
385 );
386 let _ = writeln!(out, " Assert.Equal(\"{escaped}\", bodyText.Trim());");
387 }
388 }
389 }
390
391 let mut header_idx = 0usize;
400 for (name, value) in &expected.headers {
401 if value == "<<absent>>" || value == "<<present>>" || value == "<<uuid>>" {
402 continue;
403 }
404 if name.to_lowercase() == "content-encoding" {
406 continue;
407 }
408 let lower = name.to_ascii_lowercase();
409 let is_content_header = matches!(
410 lower.as_str(),
411 "content-type"
412 | "content-length"
413 | "content-encoding"
414 | "content-language"
415 | "content-location"
416 | "content-md5"
417 | "content-range"
418 | "content-disposition"
419 | "expires"
420 | "last-modified"
421 | "allow"
422 );
423 let target = if is_content_header {
424 "response.Content.Headers"
425 } else {
426 "response.Headers"
427 };
428 let escaped_name = escape_csharp(name);
429 let escaped_value = escape_csharp(value);
430 let var_name = format!("hdr{header_idx}");
431 header_idx += 1;
432 let _ = writeln!(
433 out,
434 " Assert.True({target}.TryGetValues(\"{escaped_name}\", out var {var_name}) && {var_name}.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");"
435 );
436 }
437
438 let _ = writeln!(out, " }}");
439}
440
441#[allow(clippy::too_many_arguments)]
442fn render_test_method(
443 out: &mut String,
444 fixture: &Fixture,
445 class_name: &str,
446 _function_name: &str,
447 exception_class: &str,
448 _result_var: &str,
449 _args: &[crate::config::ArgMapping],
450 field_resolver: &FieldResolver,
451 result_is_simple: bool,
452 _is_async: bool,
453 e2e_config: &E2eConfig,
454 enum_fields: &HashMap<String, String>,
455) {
456 let method_name = fixture.id.to_upper_camel_case();
457 let description = &fixture.description;
458
459 if let Some(http) = &fixture.http {
461 render_http_test_method(out, fixture, http);
462 return;
463 }
464
465 if fixture.mock_response.is_none() {
469 let _ = writeln!(
470 out,
471 " [Fact(Skip = \"non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function\")]"
472 );
473 let _ = writeln!(out, " public void Test_{method_name}()");
474 let _ = writeln!(out, " {{");
475 let _ = writeln!(out, " // {description}");
476 let _ = writeln!(out, " }}");
477 return;
478 }
479
480 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
481
482 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
485 let lang = "csharp";
486 let cs_overrides = call_config.overrides.get(lang);
487 let effective_function_name = cs_overrides
488 .and_then(|o| o.function.as_ref())
489 .cloned()
490 .unwrap_or_else(|| call_config.function.to_upper_camel_case());
491 let effective_result_var = &call_config.result_var;
492 let effective_is_async = call_config.r#async;
493 let function_name = effective_function_name.as_str();
494 let result_var = effective_result_var.as_str();
495 let is_async = effective_is_async;
496 let args = call_config.args.as_slice();
497
498 let per_call_result_is_simple = cs_overrides.is_some_and(|o| o.result_is_simple);
500 let effective_result_is_simple = result_is_simple || per_call_result_is_simple;
501 let returns_void = call_config.returns_void;
502 let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
503 let top_level_options_type = e2e_config
505 .call
506 .overrides
507 .get("csharp")
508 .and_then(|o| o.options_type.as_deref());
509 let effective_options_type = cs_overrides
510 .and_then(|o| o.options_type.as_deref())
511 .or(top_level_options_type);
512
513 let (mut setup_lines, args_str) = build_args_and_setup(
514 &fixture.input,
515 args,
516 class_name,
517 effective_options_type,
518 enum_fields,
519 &fixture.id,
520 );
521
522 let mut visitor_arg = String::new();
524 if let Some(visitor_spec) = &fixture.visitor {
525 visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_spec);
526 }
527
528 let args_with_visitor = if visitor_arg.is_empty() {
529 args_str
530 } else {
531 format!("{args_str}, {visitor_arg}")
532 };
533
534 let final_args = if extra_args_slice.is_empty() {
535 args_with_visitor
536 } else if args_with_visitor.is_empty() {
537 extra_args_slice.join(", ")
538 } else {
539 format!("{args_with_visitor}, {}", extra_args_slice.join(", "))
540 };
541
542 let return_type = if is_async { "async Task" } else { "void" };
543 let await_kw = if is_async { "await " } else { "" };
544
545 let _ = writeln!(out, " [Fact]");
546 let _ = writeln!(out, " public {return_type} Test_{method_name}()");
547 let _ = writeln!(out, " {{");
548 let _ = writeln!(out, " // {description}");
549
550 for line in &setup_lines {
551 let _ = writeln!(out, " {line}");
552 }
553
554 if expects_error {
555 if is_async {
556 let _ = writeln!(
557 out,
558 " await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
559 );
560 } else {
561 let _ = writeln!(
562 out,
563 " Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
564 );
565 }
566 let _ = writeln!(out, " }}");
567 return;
568 }
569
570 let result_is_vec = cs_overrides.is_some_and(|o| o.result_is_vec);
571
572 if returns_void {
573 let _ = writeln!(out, " {await_kw}{class_name}.{function_name}({final_args});");
574 } else {
575 let _ = writeln!(
576 out,
577 " var {result_var} = {await_kw}{class_name}.{function_name}({final_args});"
578 );
579 for assertion in &fixture.assertions {
580 render_assertion(
581 out,
582 assertion,
583 result_var,
584 class_name,
585 exception_class,
586 field_resolver,
587 effective_result_is_simple,
588 result_is_vec,
589 );
590 }
591 }
592
593 let _ = writeln!(out, " }}");
594}
595
596fn build_args_and_setup(
600 input: &serde_json::Value,
601 args: &[crate::config::ArgMapping],
602 class_name: &str,
603 options_type: Option<&str>,
604 _enum_fields: &HashMap<String, String>,
605 fixture_id: &str,
606) -> (Vec<String>, String) {
607 if args.is_empty() {
608 return (Vec::new(), String::new());
609 }
610
611 let mut setup_lines: Vec<String> = Vec::new();
612 let mut parts: Vec<String> = Vec::new();
613
614 for arg in args {
615 if arg.arg_type == "bytes" {
616 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
619 let val = input.get(field);
620 match val {
621 None | Some(serde_json::Value::Null) if arg.optional => {
622 parts.push("null".to_string());
623 }
624 None | Some(serde_json::Value::Null) => {
625 parts.push("System.Array.Empty<byte>()".to_string());
626 }
627 Some(v) => {
628 let cs_str = json_to_csharp(v);
629 parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
630 }
631 }
632 continue;
633 }
634
635 if arg.arg_type == "mock_url" {
636 setup_lines.push(format!(
637 "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
638 arg.name,
639 ));
640 parts.push(arg.name.clone());
641 continue;
642 }
643
644 if arg.arg_type == "handle" {
645 let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
647 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
648 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
649 if config_value.is_null()
650 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
651 {
652 setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
653 } else {
654 let sorted = sort_discriminator_first(config_value.clone());
658 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
659 let name = &arg.name;
660 setup_lines.push(format!(
661 "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
662 escape_csharp(&json_str),
663 ));
664 setup_lines.push(format!(
665 "var {} = {class_name}.{constructor_name}({name}Config);",
666 arg.name,
667 name = name,
668 ));
669 }
670 parts.push(arg.name.clone());
671 continue;
672 }
673
674 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
675 let val = input.get(field);
676 match val {
677 None | Some(serde_json::Value::Null) if arg.optional => {
678 parts.push("null".to_string());
681 continue;
682 }
683 None | Some(serde_json::Value::Null) => {
684 let default_val = match arg.arg_type.as_str() {
686 "string" => "\"\"".to_string(),
687 "int" | "integer" => "0".to_string(),
688 "float" | "number" => "0.0d".to_string(),
689 "bool" | "boolean" => "false".to_string(),
690 _ => "null".to_string(),
691 };
692 parts.push(default_val);
693 }
694 Some(v) => {
695 if arg.arg_type == "json_object" {
696 if let Some(arr) = v.as_array() {
698 parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
699 continue;
700 }
701 if let Some(opts_type) = options_type {
704 if v.is_object() {
705 let json_str = serde_json::to_string(v).unwrap_or_default();
706 parts.push(format!(
707 "JsonSerializer.Deserialize<{opts_type}>(\"{}\", ConfigOptions)!",
708 escape_csharp(&json_str),
709 ));
710 continue;
711 }
712 }
713 }
714 parts.push(json_to_csharp(v));
715 }
716 }
717 }
718
719 (setup_lines, parts.join(", "))
720}
721
722fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
729 match element_type {
730 Some("f32") => {
731 let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
732 format!("new List<float>() {{ {} }}", items.join(", "))
733 }
734 Some("(String, String)") => {
735 let items: Vec<String> = arr
736 .iter()
737 .map(|v| {
738 let strs: Vec<String> = v
739 .as_array()
740 .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
741 format!("new List<string>() {{ {} }}", strs.join(", "))
742 })
743 .collect();
744 format!("new List<List<string>>() {{ {} }}", items.join(", "))
745 }
746 _ => {
747 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
748 format!("new List<string>() {{ {} }}", items.join(", "))
749 }
750 }
751}
752
753#[allow(clippy::too_many_arguments)]
754fn render_assertion(
755 out: &mut String,
756 assertion: &Assertion,
757 result_var: &str,
758 class_name: &str,
759 exception_class: &str,
760 field_resolver: &FieldResolver,
761 result_is_simple: bool,
762 result_is_vec: bool,
763) {
764 if let Some(f) = &assertion.field {
767 match f.as_str() {
768 "chunks_have_content" => {
769 let pred = format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
770 match assertion.assertion_type.as_str() {
771 "is_true" => {
772 let _ = writeln!(out, " Assert.True({pred});");
773 }
774 "is_false" => {
775 let _ = writeln!(out, " Assert.False({pred});");
776 }
777 _ => {
778 let _ = writeln!(
779 out,
780 " // skipped: unsupported assertion type on synthetic field '{f}'"
781 );
782 }
783 }
784 return;
785 }
786 "chunks_have_embeddings" => {
787 let pred =
788 format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
789 match assertion.assertion_type.as_str() {
790 "is_true" => {
791 let _ = writeln!(out, " Assert.True({pred});");
792 }
793 "is_false" => {
794 let _ = writeln!(out, " Assert.False({pred});");
795 }
796 _ => {
797 let _ = writeln!(
798 out,
799 " // skipped: unsupported assertion type on synthetic field '{f}'"
800 );
801 }
802 }
803 return;
804 }
805 "embeddings" => {
809 match assertion.assertion_type.as_str() {
810 "count_equals" => {
811 if let Some(val) = &assertion.value {
812 let cs_val = json_to_csharp(val);
813 let _ = writeln!(out, " Assert.True({result_var}.Count == {cs_val});");
814 }
815 }
816 "count_min" => {
817 if let Some(val) = &assertion.value {
818 let cs_val = json_to_csharp(val);
819 let _ = writeln!(out, " Assert.True({result_var}.Count >= {cs_val});");
820 }
821 }
822 "not_empty" => {
823 let _ = writeln!(out, " Assert.NotEmpty({result_var});");
824 }
825 "is_empty" => {
826 let _ = writeln!(out, " Assert.Empty({result_var});");
827 }
828 _ => {
829 let _ = writeln!(
830 out,
831 " // skipped: unsupported assertion type on synthetic field 'embeddings'"
832 );
833 }
834 }
835 return;
836 }
837 "embedding_dimensions" => {
838 let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
839 match assertion.assertion_type.as_str() {
840 "equals" => {
841 if let Some(val) = &assertion.value {
842 let cs_val = json_to_csharp(val);
843 let _ = writeln!(out, " Assert.True({expr} == {cs_val});");
844 }
845 }
846 "greater_than" => {
847 if let Some(val) = &assertion.value {
848 let cs_val = json_to_csharp(val);
849 let _ = writeln!(out, " Assert.True({expr} > {cs_val});");
850 }
851 }
852 _ => {
853 let _ = writeln!(
854 out,
855 " // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
856 );
857 }
858 }
859 return;
860 }
861 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
862 let pred = match f.as_str() {
863 "embeddings_valid" => {
864 format!("{result_var}.All(e => e.Count > 0)")
865 }
866 "embeddings_finite" => {
867 format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
868 }
869 "embeddings_non_zero" => {
870 format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
871 }
872 "embeddings_normalized" => {
873 format!(
874 "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
875 )
876 }
877 _ => unreachable!(),
878 };
879 match assertion.assertion_type.as_str() {
880 "is_true" => {
881 let _ = writeln!(out, " Assert.True({pred});");
882 }
883 "is_false" => {
884 let _ = writeln!(out, " Assert.False({pred});");
885 }
886 _ => {
887 let _ = writeln!(
888 out,
889 " // skipped: unsupported assertion type on synthetic field '{f}'"
890 );
891 }
892 }
893 return;
894 }
895 "keywords" | "keywords_count" => {
898 let _ = writeln!(
899 out,
900 " // skipped: field '{f}' not available on C# ExtractionResult"
901 );
902 return;
903 }
904 _ => {}
905 }
906 }
907
908 if let Some(f) = &assertion.field {
910 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
911 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
912 return;
913 }
914 }
915
916 let effective_result_var: String = if result_is_vec {
918 format!("{result_var}[0]")
919 } else {
920 result_var.to_string()
921 };
922
923 let field_expr = if result_is_simple {
924 effective_result_var.clone()
925 } else {
926 match &assertion.field {
927 Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
928 _ => effective_result_var.clone(),
929 }
930 };
931
932 match assertion.assertion_type.as_str() {
933 "equals" => {
934 if let Some(expected) = &assertion.value {
935 let cs_val = json_to_csharp(expected);
936 if expected.is_string() {
937 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr}.Trim());");
939 } else if expected.as_bool() == Some(true) {
940 let _ = writeln!(out, " Assert.True({field_expr});");
942 } else if expected.as_bool() == Some(false) {
943 let _ = writeln!(out, " Assert.False({field_expr});");
945 } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
946 let _ = writeln!(out, " Assert.True({field_expr} == {cs_val});");
949 } else {
950 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr});");
951 }
952 }
953 }
954 "contains" => {
955 if let Some(expected) = &assertion.value {
956 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
961 let cs_val = lower_expected
962 .as_deref()
963 .map(|s| format!("\"{}\"", escape_csharp(s)))
964 .unwrap_or_else(|| json_to_csharp(expected));
965 let _ = writeln!(
966 out,
967 " Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
968 );
969 }
970 }
971 "contains_all" => {
972 if let Some(values) = &assertion.values {
973 for val in values {
974 let lower_val = val.as_str().map(|s| s.to_lowercase());
975 let cs_val = lower_val
976 .as_deref()
977 .map(|s| format!("\"{}\"", escape_csharp(s)))
978 .unwrap_or_else(|| json_to_csharp(val));
979 let _ = writeln!(
980 out,
981 " Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
982 );
983 }
984 }
985 }
986 "not_contains" => {
987 if let Some(expected) = &assertion.value {
988 let cs_val = json_to_csharp(expected);
989 let _ = writeln!(out, " Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
990 }
991 }
992 "not_empty" => {
993 let _ = writeln!(
994 out,
995 " Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
996 );
997 }
998 "is_empty" => {
999 let _ = writeln!(
1000 out,
1001 " Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
1002 );
1003 }
1004 "contains_any" => {
1005 if let Some(values) = &assertion.values {
1006 let checks: Vec<String> = values
1007 .iter()
1008 .map(|v| {
1009 let cs_val = json_to_csharp(v);
1010 format!("{field_expr}.ToString().Contains({cs_val})")
1011 })
1012 .collect();
1013 let joined = checks.join(" || ");
1014 let _ = writeln!(
1015 out,
1016 " Assert.True({joined}, \"expected to contain at least one of the specified values\");"
1017 );
1018 }
1019 }
1020 "greater_than" => {
1021 if let Some(val) = &assertion.value {
1022 let cs_val = json_to_csharp(val);
1023 let _ = writeln!(
1024 out,
1025 " Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
1026 );
1027 }
1028 }
1029 "less_than" => {
1030 if let Some(val) = &assertion.value {
1031 let cs_val = json_to_csharp(val);
1032 let _ = writeln!(
1033 out,
1034 " Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
1035 );
1036 }
1037 }
1038 "greater_than_or_equal" => {
1039 if let Some(val) = &assertion.value {
1040 let cs_val = json_to_csharp(val);
1041 let _ = writeln!(
1042 out,
1043 " Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1044 );
1045 }
1046 }
1047 "less_than_or_equal" => {
1048 if let Some(val) = &assertion.value {
1049 let cs_val = json_to_csharp(val);
1050 let _ = writeln!(
1051 out,
1052 " Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
1053 );
1054 }
1055 }
1056 "starts_with" => {
1057 if let Some(expected) = &assertion.value {
1058 let cs_val = json_to_csharp(expected);
1059 let _ = writeln!(out, " Assert.StartsWith({cs_val}, {field_expr});");
1060 }
1061 }
1062 "ends_with" => {
1063 if let Some(expected) = &assertion.value {
1064 let cs_val = json_to_csharp(expected);
1065 let _ = writeln!(out, " Assert.EndsWith({cs_val}, {field_expr});");
1066 }
1067 }
1068 "min_length" => {
1069 if let Some(val) = &assertion.value {
1070 if let Some(n) = val.as_u64() {
1071 let _ = writeln!(
1072 out,
1073 " Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
1074 );
1075 }
1076 }
1077 }
1078 "max_length" => {
1079 if let Some(val) = &assertion.value {
1080 if let Some(n) = val.as_u64() {
1081 let _ = writeln!(
1082 out,
1083 " Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
1084 );
1085 }
1086 }
1087 }
1088 "count_min" => {
1089 if let Some(val) = &assertion.value {
1090 if let Some(n) = val.as_u64() {
1091 let _ = writeln!(
1092 out,
1093 " Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
1094 );
1095 }
1096 }
1097 }
1098 "count_equals" => {
1099 if let Some(val) = &assertion.value {
1100 if let Some(n) = val.as_u64() {
1101 let _ = writeln!(out, " Assert.Equal({n}, {field_expr}.Count);");
1102 }
1103 }
1104 }
1105 "is_true" => {
1106 let _ = writeln!(out, " Assert.True({field_expr});");
1107 }
1108 "is_false" => {
1109 let _ = writeln!(out, " Assert.False({field_expr});");
1110 }
1111 "not_error" => {
1112 }
1114 "error" => {
1115 }
1117 "method_result" => {
1118 if let Some(method_name) = &assertion.method {
1119 let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
1120 let check = assertion.check.as_deref().unwrap_or("is_true");
1121 match check {
1122 "equals" => {
1123 if let Some(val) = &assertion.value {
1124 if val.as_bool() == Some(true) {
1125 let _ = writeln!(out, " Assert.True({call_expr});");
1126 } else if val.as_bool() == Some(false) {
1127 let _ = writeln!(out, " Assert.False({call_expr});");
1128 } else {
1129 let cs_val = json_to_csharp(val);
1130 let _ = writeln!(out, " Assert.Equal({cs_val}, {call_expr});");
1131 }
1132 }
1133 }
1134 "is_true" => {
1135 let _ = writeln!(out, " Assert.True({call_expr});");
1136 }
1137 "is_false" => {
1138 let _ = writeln!(out, " Assert.False({call_expr});");
1139 }
1140 "greater_than_or_equal" => {
1141 if let Some(val) = &assertion.value {
1142 let n = val.as_u64().unwrap_or(0);
1143 let _ = writeln!(out, " Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
1144 }
1145 }
1146 "count_min" => {
1147 if let Some(val) = &assertion.value {
1148 let n = val.as_u64().unwrap_or(0);
1149 let _ = writeln!(
1150 out,
1151 " Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
1152 );
1153 }
1154 }
1155 "is_error" => {
1156 let _ = writeln!(
1157 out,
1158 " Assert.Throws<{exception_class}>(() => {{ {call_expr}; }});"
1159 );
1160 }
1161 "contains" => {
1162 if let Some(val) = &assertion.value {
1163 let cs_val = json_to_csharp(val);
1164 let _ = writeln!(out, " Assert.Contains({cs_val}, {call_expr});");
1165 }
1166 }
1167 other_check => {
1168 panic!("C# e2e generator: unsupported method_result check type: {other_check}");
1169 }
1170 }
1171 } else {
1172 panic!("C# e2e generator: method_result assertion missing 'method' field");
1173 }
1174 }
1175 "matches_regex" => {
1176 if let Some(expected) = &assertion.value {
1177 let cs_val = json_to_csharp(expected);
1178 let _ = writeln!(out, " Assert.Matches({cs_val}, {field_expr});");
1179 }
1180 }
1181 other => {
1182 panic!("C# e2e generator: unsupported assertion type: {other}");
1183 }
1184 }
1185}
1186
1187fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
1194 match value {
1195 serde_json::Value::Object(map) => {
1196 let mut sorted = serde_json::Map::with_capacity(map.len());
1197 if let Some(type_val) = map.get("type") {
1199 sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
1200 }
1201 for (k, v) in map {
1202 if k != "type" {
1203 sorted.insert(k, sort_discriminator_first(v));
1204 }
1205 }
1206 serde_json::Value::Object(sorted)
1207 }
1208 serde_json::Value::Array(arr) => {
1209 serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
1210 }
1211 other => other,
1212 }
1213}
1214
1215fn json_to_csharp(value: &serde_json::Value) -> String {
1217 match value {
1218 serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
1219 serde_json::Value::Bool(true) => "true".to_string(),
1220 serde_json::Value::Bool(false) => "false".to_string(),
1221 serde_json::Value::Number(n) => {
1222 if n.is_f64() {
1223 format!("{}d", n)
1224 } else {
1225 n.to_string()
1226 }
1227 }
1228 serde_json::Value::Null => "null".to_string(),
1229 serde_json::Value::Array(arr) => {
1230 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1231 format!("new[] {{ {} }}", items.join(", "))
1232 }
1233 serde_json::Value::Object(_) => {
1234 let json_str = serde_json::to_string(value).unwrap_or_default();
1235 format!("\"{}\"", escape_csharp(&json_str))
1236 }
1237 }
1238}
1239
1240fn build_csharp_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1246 setup_lines.push("var _testVisitor = new TestVisitor();".to_string());
1247 setup_lines.push("class TestVisitor : IVisitor".to_string());
1248 setup_lines.push("{".to_string());
1249 for (method_name, action) in &visitor_spec.callbacks {
1250 emit_csharp_visitor_method(setup_lines, method_name, action);
1251 }
1252 setup_lines.push("}".to_string());
1253 "_testVisitor".to_string()
1254}
1255
1256fn emit_csharp_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1258 let camel_method = method_to_camel(method_name);
1259 let params = match method_name {
1260 "visit_link" => "VisitContext ctx, string href, string text, string title",
1261 "visit_image" => "VisitContext ctx, string src, string alt, string title",
1262 "visit_heading" => "VisitContext ctx, int level, string text, string id",
1263 "visit_code_block" => "VisitContext ctx, string lang, string code",
1264 "visit_code_inline"
1265 | "visit_strong"
1266 | "visit_emphasis"
1267 | "visit_strikethrough"
1268 | "visit_underline"
1269 | "visit_subscript"
1270 | "visit_superscript"
1271 | "visit_mark"
1272 | "visit_button"
1273 | "visit_summary"
1274 | "visit_figcaption"
1275 | "visit_definition_term"
1276 | "visit_definition_description" => "VisitContext ctx, string text",
1277 "visit_text" => "VisitContext ctx, string text",
1278 "visit_list_item" => "VisitContext ctx, bool ordered, string marker, string text",
1279 "visit_blockquote" => "VisitContext ctx, string content, int depth",
1280 "visit_table_row" => "VisitContext ctx, IReadOnlyList<string> cells, bool isHeader",
1281 "visit_custom_element" => "VisitContext ctx, string tagName, string html",
1282 "visit_form" => "VisitContext ctx, string actionUrl, string method",
1283 "visit_input" => "VisitContext ctx, string inputType, string name, string value",
1284 "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, string src",
1285 "visit_details" => "VisitContext ctx, bool isOpen",
1286 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1287 "VisitContext ctx, string output"
1288 }
1289 "visit_list_start" => "VisitContext ctx, bool ordered",
1290 "visit_list_end" => "VisitContext ctx, bool ordered, string output",
1291 _ => "VisitContext ctx",
1292 };
1293
1294 setup_lines.push(format!(" public VisitResult {camel_method}({params})"));
1295 setup_lines.push(" {".to_string());
1296 match action {
1297 CallbackAction::Skip => {
1298 setup_lines.push(" return VisitResult.Skip();".to_string());
1299 }
1300 CallbackAction::Continue => {
1301 setup_lines.push(" return VisitResult.Continue();".to_string());
1302 }
1303 CallbackAction::PreserveHtml => {
1304 setup_lines.push(" return VisitResult.PreserveHtml();".to_string());
1305 }
1306 CallbackAction::Custom { output } => {
1307 let escaped = escape_csharp(output);
1308 setup_lines.push(format!(" return VisitResult.Custom(\"{escaped}\");"));
1309 }
1310 CallbackAction::CustomTemplate { template } => {
1311 let escaped = escape_csharp(template);
1312 setup_lines.push(format!(" return VisitResult.Custom($\"{escaped}\");"));
1313 }
1314 }
1315 setup_lines.push(" }".to_string());
1316}
1317
1318fn method_to_camel(snake: &str) -> String {
1320 use heck::ToUpperCamelCase;
1321 snake.to_upper_camel_case()
1322}
1323
1324fn build_csharp_method_call(
1329 result_var: &str,
1330 method_name: &str,
1331 args: Option<&serde_json::Value>,
1332 class_name: &str,
1333) -> String {
1334 match method_name {
1335 "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
1336 "root_node_type" => format!("{result_var}.RootNode.Kind"),
1337 "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
1338 "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
1339 "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
1340 "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
1341 "contains_node_type" => {
1342 let node_type = args
1343 .and_then(|a| a.get("node_type"))
1344 .and_then(|v| v.as_str())
1345 .unwrap_or("");
1346 format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
1347 }
1348 "find_nodes_by_type" => {
1349 let node_type = args
1350 .and_then(|a| a.get("node_type"))
1351 .and_then(|v| v.as_str())
1352 .unwrap_or("");
1353 format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
1354 }
1355 "run_query" => {
1356 let query_source = args
1357 .and_then(|a| a.get("query_source"))
1358 .and_then(|v| v.as_str())
1359 .unwrap_or("");
1360 let language = args
1361 .and_then(|a| a.get("language"))
1362 .and_then(|v| v.as_str())
1363 .unwrap_or("");
1364 format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1365 }
1366 _ => {
1367 use heck::ToUpperCamelCase;
1368 let pascal = method_name.to_upper_camel_case();
1369 format!("{result_var}.{pascal}()")
1370 }
1371 }
1372}