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