use crate::config::E2eConfig;
use crate::escape::{escape_csharp, sanitize_filename, sanitize_ident};
use crate::field_access::FieldResolver;
use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
use alef_core::backend::GeneratedFile;
use alef_core::config::ResolvedCrateConfig;
use alef_core::hash::{self, CommentStyle};
use alef_core::template_versions as tv;
use anyhow::Result;
use heck::ToUpperCamelCase;
use std::collections::HashMap;
use std::fmt::Write as FmtWrite;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use super::E2eCodegen;
use super::client;
pub struct CSharpCodegen;
impl E2eCodegen for CSharpCodegen {
fn generate(
&self,
groups: &[FixtureGroup],
e2e_config: &E2eConfig,
config: &ResolvedCrateConfig,
) -> Result<Vec<GeneratedFile>> {
let lang = self.language_name();
let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
let mut files = Vec::new();
let call = &e2e_config.call;
let overrides = call.overrides.get(lang);
let function_name = overrides
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| call.function.to_upper_camel_case());
let class_name = overrides
.and_then(|o| o.class.as_ref())
.cloned()
.unwrap_or_else(|| format!("{}Lib", config.name.to_upper_camel_case()));
let exception_class = format!("{}Exception", config.name.to_upper_camel_case());
let namespace = overrides
.and_then(|o| o.module.as_ref())
.cloned()
.or_else(|| config.csharp.as_ref().and_then(|cs| cs.namespace.clone()))
.unwrap_or_else(|| {
if call.module.is_empty() {
"Kreuzberg".to_string()
} else {
call.module.to_upper_camel_case()
}
});
let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
let result_var = &call.result_var;
let is_async = call.r#async;
let cs_pkg = e2e_config.resolve_package("csharp");
let pkg_name = cs_pkg
.as_ref()
.and_then(|p| p.name.as_ref())
.cloned()
.unwrap_or_else(|| config.name.to_upper_camel_case());
let pkg_path = cs_pkg
.as_ref()
.and_then(|p| p.path.as_ref())
.cloned()
.unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}/{pkg_name}.csproj"));
let pkg_version = cs_pkg
.as_ref()
.and_then(|p| p.version.as_ref())
.cloned()
.or_else(|| config.resolved_version())
.unwrap_or_else(|| "0.1.0".to_string());
let csproj_name = format!("{pkg_name}.E2eTests.csproj");
files.push(GeneratedFile {
path: output_base.join(&csproj_name),
content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
generated_header: false,
});
files.push(GeneratedFile {
path: output_base.join("TestSetup.cs"),
content: render_test_setup(),
generated_header: true,
});
let tests_base = output_base.join("tests");
let field_resolver = FieldResolver::new(
&e2e_config.fields,
&e2e_config.fields_optional,
&e2e_config.result_fields,
&e2e_config.fields_array,
&std::collections::HashSet::new(),
);
static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
let mut effective_nested_types = default_csharp_nested_types();
if let Some(overrides_map) = overrides.map(|o| &o.nested_types) {
effective_nested_types.extend(overrides_map.clone());
}
for group in groups {
let active: Vec<&Fixture> = group
.fixtures
.iter()
.filter(|f| super::should_include_fixture(f, lang, e2e_config))
.collect();
if active.is_empty() {
continue;
}
let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
let filename = format!("{test_class}.cs");
let content = render_test_file(
&group.category,
&active,
&namespace,
&class_name,
&function_name,
&exception_class,
result_var,
&test_class,
&e2e_config.call.args,
&field_resolver,
result_is_simple,
is_async,
e2e_config,
enum_fields,
&effective_nested_types,
);
files.push(GeneratedFile {
path: tests_base.join(filename),
content,
generated_header: true,
});
}
Ok(files)
}
fn language_name(&self) -> &'static str {
"csharp"
}
}
fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
let pkg_ref = match dep_mode {
crate::config::DependencyMode::Registry => {
format!(" <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
}
crate::config::DependencyMode::Local => {
format!(" <ProjectReference Include=\"{pkg_path}\" />")
}
};
crate::template_env::render(
"csharp/csproj.jinja",
minijinja::context! {
pkg_ref => pkg_ref,
microsoft_net_test_sdk_version => tv::nuget::MICROSOFT_NET_TEST_SDK,
xunit_version => tv::nuget::XUNIT,
xunit_runner_version => tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
},
)
}
fn render_test_setup() -> String {
let mut out = String::new();
out.push_str(&hash::header(CommentStyle::DoubleSlash));
out.push_str(
r#"using System;
using System.IO;
using System.Runtime.CompilerServices;
namespace Kreuzberg.E2eTests;
internal static class TestSetup
{
[ModuleInitializer]
internal static void Init()
{
// Walk up from the assembly directory until we find the repo root
// (the directory containing test_documents/) so that fixture paths
// like "docx/fake.docx" resolve regardless of where dotnet test
// launched the runner from.
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var candidate = Path.Combine(dir.FullName, "test_documents");
if (Directory.Exists(candidate))
{
Directory.SetCurrentDirectory(candidate);
return;
}
dir = dir.Parent;
}
}
}
"#,
);
out
}
#[allow(clippy::too_many_arguments)]
fn render_test_file(
category: &str,
fixtures: &[&Fixture],
namespace: &str,
class_name: &str,
function_name: &str,
exception_class: &str,
result_var: &str,
test_class: &str,
args: &[crate::config::ArgMapping],
field_resolver: &FieldResolver,
result_is_simple: bool,
is_async: bool,
e2e_config: &E2eConfig,
enum_fields: &HashMap<String, String>,
nested_types: &HashMap<String, String>,
) -> String {
let mut using_imports = String::new();
using_imports.push_str("using System;\n");
using_imports.push_str("using System.Collections.Generic;\n");
using_imports.push_str("using System.Linq;\n");
using_imports.push_str("using System.Net.Http;\n");
using_imports.push_str("using System.Text;\n");
using_imports.push_str("using System.Text.Json;\n");
using_imports.push_str("using System.Text.Json.Serialization;\n");
using_imports.push_str("using System.Threading.Tasks;\n");
using_imports.push_str("using Xunit;\n");
using_imports.push_str(&format!("using {namespace};\n"));
using_imports.push_str(&format!("using static {namespace}.{class_name};\n"));
let config_options_field = " private static readonly JsonSerializerOptions ConfigOptions = new() { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault };";
let mut visitor_class_decls: Vec<String> = Vec::new();
let mut fixtures_body = String::new();
for (i, fixture) in fixtures.iter().enumerate() {
render_test_method(
&mut fixtures_body,
&mut visitor_class_decls,
fixture,
class_name,
function_name,
exception_class,
result_var,
args,
field_resolver,
result_is_simple,
is_async,
e2e_config,
enum_fields,
nested_types,
);
if i + 1 < fixtures.len() {
fixtures_body.push('\n');
}
}
let mut visitor_classes_str = String::new();
for (i, decl) in visitor_class_decls.iter().enumerate() {
if i > 0 {
visitor_classes_str.push('\n');
}
visitor_classes_str.push('\n');
for line in decl.lines() {
visitor_classes_str.push_str(" ");
visitor_classes_str.push_str(line);
visitor_classes_str.push('\n');
}
}
let ctx = minijinja::context! {
header => hash::header(CommentStyle::DoubleSlash),
using_imports => using_imports,
category => category,
namespace => namespace,
test_class => test_class,
config_options_field => config_options_field,
fixtures_body => fixtures_body,
visitor_class_decls => visitor_classes_str,
};
crate::template_env::render("csharp/test_file.jinja", ctx)
}
struct CSharpTestClientRenderer;
fn to_csharp_http_method(method: &str) -> String {
let lower = method.to_ascii_lowercase();
let mut chars = lower.chars();
match chars.next() {
Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
}
const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
"content-length",
"host",
"connection",
"expect",
"transfer-encoding",
"upgrade",
"content-type",
"content-encoding",
"content-language",
"content-location",
"content-md5",
"content-range",
"content-disposition",
];
fn is_csharp_content_header(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"content-type"
| "content-length"
| "content-encoding"
| "content-language"
| "content-location"
| "content-md5"
| "content-range"
| "content-disposition"
| "expires"
| "last-modified"
| "allow"
)
}
impl client::TestClientRenderer for CSharpTestClientRenderer {
fn language_name(&self) -> &'static str {
"csharp"
}
fn sanitize_test_name(&self, id: &str) -> String {
id.to_upper_camel_case()
}
fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
let escaped_reason = skip_reason.map(escape_csharp);
let rendered = crate::template_env::render(
"csharp/http_test_open.jinja",
minijinja::context! {
fn_name => fn_name,
description => description,
skip_reason => escaped_reason,
},
);
out.push_str(&rendered);
}
fn render_test_close(&self, out: &mut String) {
let rendered = crate::template_env::render("csharp/http_test_close.jinja", minijinja::context! {});
out.push_str(&rendered);
}
fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
let method = to_csharp_http_method(ctx.method);
let path = escape_csharp(ctx.path);
out.push_str(" var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";\n");
out.push_str(
" using var handler = new System.Net.Http.HttpClientHandler { AllowAutoRedirect = false };\n",
);
out.push_str(" using var client = new System.Net.Http.HttpClient(handler);\n");
out.push_str(&format!(" var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}{path}\");\n"));
if let Some(body) = ctx.body {
let content_type = ctx.content_type.unwrap_or("application/json");
let json_str = serde_json::to_string(body).unwrap_or_default();
let escaped = escape_csharp(&json_str);
out.push_str(&format!(" request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");\n"));
}
for (name, value) in ctx.headers {
if CSHARP_RESTRICTED_REQUEST_HEADERS.contains(&name.to_lowercase().as_str()) {
continue;
}
let escaped_name = escape_csharp(name);
let escaped_value = escape_csharp(value);
out.push_str(&format!(
" request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");\n"
));
}
if !ctx.cookies.is_empty() {
let mut pairs: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
pairs.sort();
let cookie_header = escape_csharp(&pairs.join("; "));
out.push_str(&format!(
" request.Headers.Add(\"Cookie\", \"{cookie_header}\");\n"
));
}
out.push_str(" var response = await client.SendAsync(request);\n");
}
fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
out.push_str(&format!(" Assert.Equal({status}, (int)response.StatusCode);\n"));
}
fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
let target = if is_csharp_content_header(name) {
"response.Content.Headers"
} else {
"response.Headers"
};
let escaped_name = escape_csharp(name);
match expected {
"<<present>>" => {
out.push_str(&format!(" Assert.True({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be present\");\n"));
}
"<<absent>>" => {
out.push_str(&format!(" Assert.False({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be absent\");\n"));
}
"<<uuid>>" => {
out.push_str(&format!(" Assert.True({target}.TryGetValues(\"{escaped_name}\", out var _uuidHdr) && System.Text.RegularExpressions.Regex.IsMatch(string.Join(\", \", _uuidHdr), @\"^[0-9a-fA-F]{{8}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{12}}$\"), \"header {escaped_name} is not a UUID\");\n"));
}
literal => {
let var_name = format!("hdr{}", sanitize_ident(name));
let escaped_value = escape_csharp(literal);
out.push_str(&format!(" Assert.True({target}.TryGetValues(\"{escaped_name}\", out var {var_name}) && {var_name}.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");\n"));
}
}
}
fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
match expected {
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
let json_str = serde_json::to_string(expected).unwrap_or_default();
let escaped = escape_csharp(&json_str);
out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
out.push_str(" var body = JsonDocument.Parse(bodyText).RootElement;\n");
out.push_str(&format!(
" var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;\n"
));
out.push_str(" Assert.Equal(expectedBody.GetRawText(), body.GetRawText());\n");
}
serde_json::Value::String(s) => {
let escaped = escape_csharp(s);
out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
out.push_str(&format!(" Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
}
other => {
let escaped = escape_csharp(&other.to_string());
out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
out.push_str(&format!(" Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
}
}
}
fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
if let Some(obj) = expected.as_object() {
out.push_str(" var partialBodyText = await response.Content.ReadAsStringAsync();\n");
out.push_str(" var partialBody = JsonDocument.Parse(partialBodyText).RootElement;\n");
for (key, val) in obj {
let escaped_key = escape_csharp(key);
let json_str = serde_json::to_string(val).unwrap_or_default();
let escaped_val = escape_csharp(&json_str);
let var_name = format!("expected{}", key.to_upper_camel_case());
out.push_str(&format!(
" var {var_name} = JsonDocument.Parse(\"{escaped_val}\").RootElement;\n"
));
out.push_str(&format!(" Assert.True(partialBody.TryGetProperty(\"{escaped_key}\", out var _partialProp{var_name}) && _partialProp{var_name}.GetRawText() == {var_name}.GetRawText(), \"partial body field '{escaped_key}' mismatch\");\n"));
}
}
}
fn render_assert_validation_errors(
&self,
out: &mut String,
_response_var: &str,
errors: &[ValidationErrorExpectation],
) {
out.push_str(" var validationBodyText = await response.Content.ReadAsStringAsync();\n");
for err in errors {
let escaped_msg = escape_csharp(&err.msg);
out.push_str(&format!(
" Assert.Contains(\"{escaped_msg}\", validationBodyText);\n"
));
}
}
}
fn render_http_test_method(out: &mut String, fixture: &Fixture, _http: &HttpFixture) {
client::http_call::render_http_test(out, &CSharpTestClientRenderer, fixture);
}
#[allow(clippy::too_many_arguments)]
fn render_test_method(
out: &mut String,
visitor_class_decls: &mut Vec<String>,
fixture: &Fixture,
class_name: &str,
_function_name: &str,
exception_class: &str,
_result_var: &str,
_args: &[crate::config::ArgMapping],
field_resolver: &FieldResolver,
result_is_simple: bool,
_is_async: bool,
e2e_config: &E2eConfig,
enum_fields: &HashMap<String, String>,
nested_types: &HashMap<String, String>,
) {
let method_name = fixture.id.to_upper_camel_case();
let description = &fixture.description;
if let Some(http) = &fixture.http {
render_http_test_method(out, fixture, http);
return;
}
if fixture.mock_response.is_none() && !fixture_has_csharp_callable(fixture, e2e_config) {
let skip_reason =
"non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function";
let ctx = minijinja::context! {
is_skipped => true,
skip_reason => skip_reason,
description => description,
method_name => method_name,
};
let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
out.push_str(&rendered);
return;
}
let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
let call_config = e2e_config.resolve_call(fixture.call.as_deref());
let lang = "csharp";
let cs_overrides = call_config.overrides.get(lang);
let effective_function_name = cs_overrides
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| call_config.function.to_upper_camel_case());
let effective_result_var = &call_config.result_var;
let effective_is_async = call_config.r#async;
let function_name = effective_function_name.as_str();
let result_var = effective_result_var.as_str();
let is_async = effective_is_async;
let args = call_config.args.as_slice();
let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
let effective_result_is_simple = result_is_simple || per_call_result_is_simple;
let returns_void = call_config.returns_void;
let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
let top_level_options_type = e2e_config
.call
.overrides
.get("csharp")
.and_then(|o| o.options_type.as_deref());
let effective_options_type = cs_overrides
.and_then(|o| o.options_type.as_deref())
.or(top_level_options_type);
let (mut setup_lines, args_str) = build_args_and_setup(
&fixture.input,
args,
class_name,
effective_options_type,
enum_fields,
nested_types,
&fixture.id,
);
let mut visitor_arg = String::new();
let has_visitor = fixture.visitor.is_some();
if let Some(visitor_spec) = &fixture.visitor {
visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_class_decls, &fixture.id, visitor_spec);
}
let final_args = if has_visitor && !visitor_arg.is_empty() {
let opts_type = effective_options_type.unwrap_or("ConversionOptions");
if args_str.contains("JsonSerializer.Deserialize") {
setup_lines.push(format!("var options = {args_str};"));
setup_lines.push(format!("options.Visitor = {visitor_arg};"));
"options".to_string()
} else if args_str.ends_with(", null") {
setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
let trimmed = args_str[..args_str.len() - 6].to_string(); format!("{trimmed}, options")
} else if args_str.contains(", null,") {
setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
args_str.replace(", null,", ", options,")
} else if args_str.is_empty() {
setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
"options".to_string()
} else {
setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
format!("{args_str}, options")
}
} else if extra_args_slice.is_empty() {
args_str
} else if args_str.is_empty() {
extra_args_slice.join(", ")
} else {
format!("{args_str}, {}", extra_args_slice.join(", "))
};
let effective_function_name = function_name.to_string();
let return_type = if is_async { "async Task" } else { "void" };
let await_kw = if is_async { "await " } else { "" };
let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
e2e_config
.call
.overrides
.get("csharp")
.and_then(|o| o.client_factory.as_deref())
});
let call_target = if client_factory.is_some() {
"client".to_string()
} else {
class_name.to_string()
};
let mut client_factory_setup = String::new();
if let Some(factory) = client_factory {
let factory_name = factory.to_upper_camel_case();
let fixture_id = &fixture.id;
client_factory_setup.push_str(&format!(" var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
client_factory_setup.push_str(&format!(
" var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
));
}
let call_expr = format!("{}({})", effective_function_name, final_args);
let mut assertions_body = String::new();
if !expects_error && !returns_void {
for assertion in &fixture.assertions {
render_assertion(
&mut assertions_body,
assertion,
result_var,
class_name,
exception_class,
field_resolver,
effective_result_is_simple,
call_config.result_is_vec || cs_overrides.is_some_and(|o| o.result_is_vec),
call_config.result_is_array,
);
}
}
let ctx = minijinja::context! {
is_skipped => false,
expects_error => expects_error,
description => description,
return_type => return_type,
method_name => method_name,
async_kw => await_kw,
call_target => call_target,
setup_lines => setup_lines.clone(),
call_expr => call_expr,
exception_class => exception_class,
client_factory_setup => client_factory_setup,
has_usable_assertion => !expects_error && !returns_void,
result_var => result_var,
assertions_body => assertions_body,
};
let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
for line in rendered.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
fn build_args_and_setup(
input: &serde_json::Value,
args: &[crate::config::ArgMapping],
class_name: &str,
options_type: Option<&str>,
enum_fields: &HashMap<String, String>,
nested_types: &HashMap<String, String>,
fixture_id: &str,
) -> (Vec<String>, String) {
if args.is_empty() {
return (Vec::new(), String::new());
}
let mut setup_lines: Vec<String> = Vec::new();
let mut parts: Vec<String> = Vec::new();
for arg in args {
if arg.arg_type == "bytes" {
let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
let val = input.get(field);
match val {
None | Some(serde_json::Value::Null) if arg.optional => {
parts.push("null".to_string());
}
None | Some(serde_json::Value::Null) => {
parts.push("System.Array.Empty<byte>()".to_string());
}
Some(v) => {
if let Some(s) = v.as_str() {
let bytes_code = classify_bytes_value_csharp(s);
parts.push(bytes_code);
} else {
let cs_str = json_to_csharp(v);
parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
}
}
}
continue;
}
if arg.arg_type == "mock_url" {
setup_lines.push(format!(
"var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
arg.name,
));
parts.push(arg.name.clone());
continue;
}
if arg.arg_type == "handle" {
let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
if config_value.is_null()
|| config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
{
setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
} else {
let sorted = sort_discriminator_first(config_value.clone());
let json_str = serde_json::to_string(&sorted).unwrap_or_default();
let name = &arg.name;
setup_lines.push(format!(
"var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
escape_csharp(&json_str),
));
setup_lines.push(format!(
"var {} = {class_name}.{constructor_name}({name}Config);",
arg.name,
name = name,
));
}
parts.push(arg.name.clone());
continue;
}
let val: Option<&serde_json::Value> = if arg.field == "input" {
Some(input)
} else {
let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
input.get(field)
};
match val {
None | Some(serde_json::Value::Null) if arg.optional => {
parts.push("null".to_string());
continue;
}
None | Some(serde_json::Value::Null) => {
let default_val = match arg.arg_type.as_str() {
"string" => "\"\"".to_string(),
"int" | "integer" => "0".to_string(),
"float" | "number" => "0.0d".to_string(),
"bool" | "boolean" => "false".to_string(),
"json_object" => {
if let Some(opts_type) = options_type {
format!("new {opts_type}()")
} else {
"null".to_string()
}
}
_ => "null".to_string(),
};
parts.push(default_val);
}
Some(v) => {
if arg.arg_type == "json_object" {
if let Some(arr) = v.as_array() {
parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
continue;
}
if let Some(opts_type) = options_type {
if let Some(obj) = v.as_object() {
parts.push(csharp_object_initializer(obj, opts_type, enum_fields, nested_types));
continue;
}
}
}
parts.push(json_to_csharp(v));
}
}
}
(setup_lines, parts.join(", "))
}
fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
match element_type {
Some("BatchBytesItem") => {
let items: Vec<String> = arr
.iter()
.filter_map(|v| v.as_object())
.map(|obj| {
let content = obj.get("content").and_then(|v| v.as_array());
let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
let content_code = if let Some(arr) = content {
let bytes: Vec<String> = arr
.iter()
.filter_map(|v| v.as_u64().map(|n| format!("(byte){}", n)))
.collect();
format!("new byte[] {{ {} }}", bytes.join(", "))
} else {
"new byte[] { }".to_string()
};
format!(
"new BatchBytesItem {{ Content = {}, MimeType = \"{}\" }}",
content_code, mime_type
)
})
.collect();
format!("new List<BatchBytesItem>() {{ {} }}", items.join(", "))
}
Some("BatchFileItem") => {
let items: Vec<String> = arr
.iter()
.filter_map(|v| v.as_object())
.map(|obj| {
let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
format!("new BatchFileItem {{ Path = \"{}\" }}", path)
})
.collect();
format!("new List<BatchFileItem>() {{ {} }}", items.join(", "))
}
Some("f32") => {
let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
format!("new List<float>() {{ {} }}", items.join(", "))
}
Some("(String, String)") => {
let items: Vec<String> = arr
.iter()
.map(|v| {
let strs: Vec<String> = v
.as_array()
.map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
format!("new List<string>() {{ {} }}", strs.join(", "))
})
.collect();
format!("new List<List<string>>() {{ {} }}", items.join(", "))
}
Some(et)
if et != "f32"
&& et != "(String, String)"
&& et != "string"
&& et != "BatchBytesItem"
&& et != "BatchFileItem" =>
{
let items: Vec<String> = arr
.iter()
.map(|v| {
let json_str = serde_json::to_string(v).unwrap_or_default();
let escaped = escape_csharp(&json_str);
format!("JsonSerializer.Deserialize<{et}>(\"{escaped}\", ConfigOptions)!")
})
.collect();
format!("new List<{et}>() {{ {} }}", items.join(", "))
}
_ => {
let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
format!("new List<string>() {{ {} }}", items.join(", "))
}
}
}
fn parse_discriminated_union_access(field: &str) -> Option<(String, String, String)> {
let parts: Vec<&str> = field.split('.').collect();
if parts.len() >= 3 && parts.len() <= 4 {
if parts[0] == "metadata" && parts[1] == "format" {
let variant_name = parts[2];
let known_variants = [
"pdf",
"docx",
"excel",
"email",
"pptx",
"archive",
"image",
"xml",
"text",
"html",
"ocr",
"csv",
"bibtex",
"citation",
"fiction_book",
"dbf",
"jats",
"epub",
"pst",
"code",
];
if known_variants.contains(&variant_name) {
let variant_pascal = variant_name.to_upper_camel_case();
if parts.len() == 4 {
let inner_field = parts[3];
return Some((
format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
variant_pascal,
inner_field.to_string(),
));
} else if parts.len() == 3 {
return Some((
format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
variant_pascal,
String::new(),
));
}
}
}
}
None
}
fn render_discriminated_union_assertion(
out: &mut String,
assertion: &Assertion,
variant_var: &str,
inner_field: &str,
_result_is_vec: bool,
) {
if inner_field.is_empty() {
return; }
let field_pascal = inner_field.to_upper_camel_case();
let field_expr = format!("{variant_var}.Value.{field_pascal}");
match assertion.assertion_type.as_str() {
"equals" => {
if let Some(expected) = &assertion.value {
let cs_val = json_to_csharp(expected);
if expected.is_string() {
let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr}!.Trim());");
} else if expected.as_bool() == Some(true) {
let _ = writeln!(out, " Assert.True({field_expr});");
} else if expected.as_bool() == Some(false) {
let _ = writeln!(out, " Assert.False({field_expr});");
} else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
let _ = writeln!(out, " Assert.True({field_expr} == {cs_val});");
} else {
let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr});");
}
}
}
"greater_than_or_equal" => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let _ = writeln!(
out,
" Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
);
}
}
"contains_all" => {
if let Some(values) = &assertion.values {
let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
for val in values {
let lower_val = val.as_str().map(|s| s.to_lowercase());
let cs_val = lower_val
.as_deref()
.map(|s| format!("\"{}\"", escape_csharp(s)))
.unwrap_or_else(|| json_to_csharp(val));
let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
}
}
}
"contains" => {
if let Some(expected) = &assertion.value {
let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
let lower_expected = expected.as_str().map(|s| s.to_lowercase());
let cs_val = lower_expected
.as_deref()
.map(|s| format!("\"{}\"", escape_csharp(s)))
.unwrap_or_else(|| json_to_csharp(expected));
let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
}
}
"not_empty" => {
let _ = writeln!(out, " Assert.NotEmpty({field_expr});");
}
"is_empty" => {
let _ = writeln!(out, " Assert.Empty({field_expr});");
}
_ => {
let _ = writeln!(
out,
" // skipped: assertion type '{}' not yet supported for discriminated union fields",
assertion.assertion_type
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_assertion(
out: &mut String,
assertion: &Assertion,
result_var: &str,
class_name: &str,
exception_class: &str,
field_resolver: &FieldResolver,
result_is_simple: bool,
result_is_vec: bool,
result_is_array: bool,
) {
if let Some(f) = &assertion.field {
match f.as_str() {
"chunks_have_content" => {
let synthetic_pred =
format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
let synthetic_pred_type = match assertion.assertion_type.as_str() {
"is_true" => "is_true",
"is_false" => "is_false",
_ => {
out.push_str(&format!(
" // skipped: unsupported assertion type on synthetic field '{f}'\n"
));
return;
}
};
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "synthetic_assertion",
synthetic_pred => synthetic_pred,
synthetic_pred_type => synthetic_pred_type,
},
);
out.push_str(&rendered);
return;
}
"chunks_have_embeddings" => {
let synthetic_pred =
format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
let synthetic_pred_type = match assertion.assertion_type.as_str() {
"is_true" => "is_true",
"is_false" => "is_false",
_ => {
out.push_str(&format!(
" // skipped: unsupported assertion type on synthetic field '{f}'\n"
));
return;
}
};
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "synthetic_assertion",
synthetic_pred => synthetic_pred,
synthetic_pred_type => synthetic_pred_type,
},
);
out.push_str(&rendered);
return;
}
"embeddings" => {
match assertion.assertion_type.as_str() {
"count_equals" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "synthetic_embeddings_count_equals",
synthetic_pred => format!("{result_var}.Count"),
n => n,
},
);
out.push_str(&rendered);
}
}
}
"count_min" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "synthetic_embeddings_count_min",
synthetic_pred => format!("{result_var}.Count"),
n => n,
},
);
out.push_str(&rendered);
}
}
}
"not_empty" => {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "synthetic_embeddings_not_empty",
synthetic_pred => result_var.to_string(),
},
);
out.push_str(&rendered);
}
"is_empty" => {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "synthetic_embeddings_is_empty",
synthetic_pred => result_var.to_string(),
},
);
out.push_str(&rendered);
}
_ => {
out.push_str(
" // skipped: unsupported assertion type on synthetic field 'embeddings'\n",
);
}
}
return;
}
"embedding_dimensions" => {
let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
match assertion.assertion_type.as_str() {
"equals" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "synthetic_embedding_dimensions_equals",
synthetic_pred => expr,
n => n,
},
);
out.push_str(&rendered);
}
}
}
"greater_than" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "synthetic_embedding_dimensions_greater_than",
synthetic_pred => expr,
n => n,
},
);
out.push_str(&rendered);
}
}
}
_ => {
out.push_str(" // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n");
}
}
return;
}
"embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
let synthetic_pred = match f.as_str() {
"embeddings_valid" => {
format!("{result_var}.All(e => e.Count > 0)")
}
"embeddings_finite" => {
format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
}
"embeddings_non_zero" => {
format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
}
"embeddings_normalized" => {
format!(
"{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
)
}
_ => unreachable!(),
};
let synthetic_pred_type = match assertion.assertion_type.as_str() {
"is_true" => "is_true",
"is_false" => "is_false",
_ => {
out.push_str(&format!(
" // skipped: unsupported assertion type on synthetic field '{f}'\n"
));
return;
}
};
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "synthetic_assertion",
synthetic_pred => synthetic_pred,
synthetic_pred_type => synthetic_pred_type,
},
);
out.push_str(&rendered);
return;
}
"keywords" | "keywords_count" => {
let skipped_reason = format!("field '{f}' not available on C# ExtractionResult");
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
skipped_reason => skipped_reason,
},
);
out.push_str(&rendered);
return;
}
_ => {}
}
}
if let Some(f) = &assertion.field {
if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
let skipped_reason = format!("field '{f}' not available on result type");
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
skipped_reason => skipped_reason,
},
);
out.push_str(&rendered);
return;
}
}
let is_count_assertion = matches!(
assertion.assertion_type.as_str(),
"count_equals" | "count_min" | "count_max"
);
let is_no_field = assertion.field.is_none() || assertion.field.as_ref().is_some_and(|f| f.is_empty());
let use_list_directly = result_is_vec && is_count_assertion && is_no_field;
let effective_result_var: String = if result_is_vec && !use_list_directly {
format!("{result_var}[0]")
} else {
result_var.to_string()
};
let is_discriminated_union = assertion
.field
.as_ref()
.is_some_and(|f| parse_discriminated_union_access(f).is_some());
if is_discriminated_union {
if let Some((_, variant_name, inner_field)) = assertion
.field
.as_ref()
.and_then(|f| parse_discriminated_union_access(f))
{
let mut hasher = std::collections::hash_map::DefaultHasher::new();
inner_field.hash(&mut hasher);
let var_hash = format!("{:x}", hasher.finish());
let variant_var = format!("variant_{}", &var_hash[..8]);
let _ = writeln!(
out,
" if ({effective_result_var}.Metadata.Format is FormatMetadata.{} {})",
variant_name, &variant_var
);
let _ = writeln!(out, " {{");
render_discriminated_union_assertion(out, assertion, &variant_var, &inner_field, result_is_vec);
let _ = writeln!(out, " }}");
let _ = writeln!(out, " else");
let _ = writeln!(out, " {{");
let _ = writeln!(
out,
" Assert.Fail(\"Expected {} format metadata\");",
variant_name.to_lowercase()
);
let _ = writeln!(out, " }}");
return;
}
}
let field_expr = if result_is_simple {
effective_result_var.clone()
} else {
match &assertion.field {
Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
_ => effective_result_var.clone(),
}
};
let field_needs_json_serialize = if result_is_simple {
result_is_array
} else {
match &assertion.field {
Some(f) if !f.is_empty() => field_resolver.is_array(f),
_ => !result_is_simple,
}
};
let field_as_str = if field_needs_json_serialize {
format!("JsonSerializer.Serialize({field_expr})")
} else {
format!("{field_expr}.ToString()")
};
match assertion.assertion_type.as_str() {
"equals" => {
if let Some(expected) = &assertion.value {
let cs_val = json_to_csharp(expected);
let is_string_val = expected.is_string();
let is_bool_true = expected.as_bool() == Some(true);
let is_bool_false = expected.as_bool() == Some(false);
let is_integer_val = expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0);
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "equals",
field_expr => field_expr.clone(),
cs_val => cs_val,
is_string_val => is_string_val,
is_bool_true => is_bool_true,
is_bool_false => is_bool_false,
is_integer_val => is_integer_val,
},
);
out.push_str(&rendered);
}
}
"contains" => {
if let Some(expected) = &assertion.value {
let lower_expected = expected.as_str().map(|s| s.to_lowercase());
let cs_val = lower_expected
.as_deref()
.map(|s| format!("\"{}\"", escape_csharp(s)))
.unwrap_or_else(|| json_to_csharp(expected));
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "contains",
field_as_str => field_as_str.clone(),
cs_val => cs_val,
},
);
out.push_str(&rendered);
}
}
"contains_all" => {
if let Some(values) = &assertion.values {
let values_cs_lower: Vec<String> = values
.iter()
.map(|val| {
let lower_val = val.as_str().map(|s| s.to_lowercase());
lower_val
.as_deref()
.map(|s| format!("\"{}\"", escape_csharp(s)))
.unwrap_or_else(|| json_to_csharp(val))
})
.collect();
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "contains_all",
field_as_str => field_as_str.clone(),
values_cs_lower => values_cs_lower,
},
);
out.push_str(&rendered);
}
}
"not_contains" => {
if let Some(expected) = &assertion.value {
let cs_val = json_to_csharp(expected);
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "not_contains",
field_as_str => field_as_str.clone(),
cs_val => cs_val,
},
);
out.push_str(&rendered);
}
}
"not_empty" => {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "not_empty",
field_expr => field_expr.clone(),
field_needs_json_serialize => field_needs_json_serialize,
},
);
out.push_str(&rendered);
}
"is_empty" => {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "is_empty",
field_expr => field_expr.clone(),
field_needs_json_serialize => field_needs_json_serialize,
},
);
out.push_str(&rendered);
}
"contains_any" => {
if let Some(values) = &assertion.values {
let checks: Vec<String> = values
.iter()
.map(|v| {
let cs_val = json_to_csharp(v);
format!("{field_as_str}.Contains({cs_val})")
})
.collect();
let contains_any_expr = checks.join(" || ");
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "contains_any",
contains_any_expr => contains_any_expr,
},
);
out.push_str(&rendered);
}
}
"greater_than" => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "greater_than",
field_expr => field_expr.clone(),
cs_val => cs_val,
},
);
out.push_str(&rendered);
}
}
"less_than" => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "less_than",
field_expr => field_expr.clone(),
cs_val => cs_val,
},
);
out.push_str(&rendered);
}
}
"greater_than_or_equal" => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "greater_than_or_equal",
field_expr => field_expr.clone(),
cs_val => cs_val,
},
);
out.push_str(&rendered);
}
}
"less_than_or_equal" => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "less_than_or_equal",
field_expr => field_expr.clone(),
cs_val => cs_val,
},
);
out.push_str(&rendered);
}
}
"starts_with" => {
if let Some(expected) = &assertion.value {
let cs_val = json_to_csharp(expected);
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "starts_with",
field_expr => field_expr.clone(),
cs_val => cs_val,
},
);
out.push_str(&rendered);
}
}
"ends_with" => {
if let Some(expected) = &assertion.value {
let cs_val = json_to_csharp(expected);
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "ends_with",
field_expr => field_expr.clone(),
cs_val => cs_val,
},
);
out.push_str(&rendered);
}
}
"min_length" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "min_length",
field_expr => field_expr.clone(),
n => n,
},
);
out.push_str(&rendered);
}
}
}
"max_length" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "max_length",
field_expr => field_expr.clone(),
n => n,
},
);
out.push_str(&rendered);
}
}
}
"count_min" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "count_min",
field_expr => field_expr.clone(),
n => n,
},
);
out.push_str(&rendered);
}
}
}
"count_equals" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "count_equals",
field_expr => field_expr.clone(),
n => n,
},
);
out.push_str(&rendered);
}
}
}
"is_true" => {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "is_true",
field_expr => field_expr.clone(),
},
);
out.push_str(&rendered);
}
"is_false" => {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "is_false",
field_expr => field_expr.clone(),
},
);
out.push_str(&rendered);
}
"not_error" => {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "not_error",
},
);
out.push_str(&rendered);
}
"error" => {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "error",
},
);
out.push_str(&rendered);
}
"method_result" => {
if let Some(method_name) = &assertion.method {
let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
let check = assertion.check.as_deref().unwrap_or("is_true");
match check {
"equals" => {
if let Some(val) = &assertion.value {
let is_check_bool_true = val.as_bool() == Some(true);
let is_check_bool_false = val.as_bool() == Some(false);
let cs_check_val = json_to_csharp(val);
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "method_result",
check => "equals",
call_expr => call_expr.clone(),
is_check_bool_true => is_check_bool_true,
is_check_bool_false => is_check_bool_false,
cs_check_val => cs_check_val,
},
);
out.push_str(&rendered);
}
}
"is_true" => {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "method_result",
check => "is_true",
call_expr => call_expr.clone(),
},
);
out.push_str(&rendered);
}
"is_false" => {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "method_result",
check => "is_false",
call_expr => call_expr.clone(),
},
);
out.push_str(&rendered);
}
"greater_than_or_equal" => {
if let Some(val) = &assertion.value {
let check_n = val.as_u64().unwrap_or(0);
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "method_result",
check => "greater_than_or_equal",
call_expr => call_expr.clone(),
check_n => check_n,
},
);
out.push_str(&rendered);
}
}
"count_min" => {
if let Some(val) = &assertion.value {
let check_n = val.as_u64().unwrap_or(0);
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "method_result",
check => "count_min",
call_expr => call_expr.clone(),
check_n => check_n,
},
);
out.push_str(&rendered);
}
}
"is_error" => {
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "method_result",
check => "is_error",
call_expr => call_expr.clone(),
exception_class => exception_class,
},
);
out.push_str(&rendered);
}
"contains" => {
if let Some(val) = &assertion.value {
let cs_check_val = json_to_csharp(val);
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "method_result",
check => "contains",
call_expr => call_expr.clone(),
cs_check_val => cs_check_val,
},
);
out.push_str(&rendered);
}
}
other_check => {
panic!("C# e2e generator: unsupported method_result check type: {other_check}");
}
}
} else {
panic!("C# e2e generator: method_result assertion missing 'method' field");
}
}
"matches_regex" => {
if let Some(expected) = &assertion.value {
let cs_val = json_to_csharp(expected);
let rendered = crate::template_env::render(
"csharp/assertion.jinja",
minijinja::context! {
assertion_type => "matches_regex",
field_expr => field_expr.clone(),
cs_val => cs_val,
},
);
out.push_str(&rendered);
}
}
other => {
panic!("C# e2e generator: unsupported assertion type: {other}");
}
}
}
fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut sorted = serde_json::Map::with_capacity(map.len());
if let Some(type_val) = map.get("type") {
sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
}
for (k, v) in map {
if k != "type" {
sorted.insert(k, sort_discriminator_first(v));
}
}
serde_json::Value::Object(sorted)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
}
other => other,
}
}
fn json_to_csharp(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
serde_json::Value::Bool(true) => "true".to_string(),
serde_json::Value::Bool(false) => "false".to_string(),
serde_json::Value::Number(n) => {
if n.is_f64() {
format!("{}d", n)
} else {
n.to_string()
}
}
serde_json::Value::Null => "null".to_string(),
serde_json::Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
format!("new[] {{ {} }}", items.join(", "))
}
serde_json::Value::Object(_) => {
let json_str = serde_json::to_string(value).unwrap_or_default();
format!("\"{}\"", escape_csharp(&json_str))
}
}
}
fn default_csharp_nested_types() -> HashMap<String, String> {
[
("chunking", "ChunkingConfig"),
("ocr", "OcrConfig"),
("images", "ImageExtractionConfig"),
("html_output", "HtmlOutputConfig"),
("language_detection", "LanguageDetectionConfig"),
("postprocessor", "PostProcessorConfig"),
("acceleration", "AccelerationConfig"),
("email", "EmailConfig"),
("pages", "PageConfig"),
("pdf_options", "PdfConfig"),
("layout", "LayoutDetectionConfig"),
("tree_sitter", "TreeSitterConfig"),
("structured_extraction", "StructuredExtractionConfig"),
("content_filter", "ContentFilterConfig"),
("token_reduction", "TokenReductionOptions"),
("security_limits", "SecurityLimits"),
("format", "FormatMetadata"),
]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn csharp_object_initializer(
obj: &serde_json::Map<String, serde_json::Value>,
type_name: &str,
enum_fields: &HashMap<String, String>,
nested_types: &HashMap<String, String>,
) -> String {
if obj.is_empty() {
return format!("new {type_name}()");
}
static IMPLICIT_ENUM_FIELDS: &[(&str, &str)] = &[("output_format", "OutputFormat")];
let props: Vec<String> = obj
.iter()
.map(|(key, val)| {
let pascal_key = key.to_upper_camel_case();
let implicit_enum_type = IMPLICIT_ENUM_FIELDS
.iter()
.find(|(k, _)| *k == key.as_str())
.map(|(_, t)| *t);
let cs_val =
if let Some(enum_type) = enum_fields.get(key.as_str()).map(String::as_str).or(implicit_enum_type) {
if val.is_null() {
"null".to_string()
} else {
let member = val
.as_str()
.map(|s| s.to_upper_camel_case())
.unwrap_or_else(|| "null".to_string());
format!("{enum_type}.{member}")
}
} else if let Some(nested_type) = nested_types.get(key.as_str()) {
let normalized = normalize_csharp_enum_values(val, enum_fields);
let json_str = serde_json::to_string(&normalized).unwrap_or_default();
format!(
"JsonSerializer.Deserialize<{nested_type}>(\"{}\", ConfigOptions)!",
escape_csharp(&json_str)
)
} else if let Some(arr) = val.as_array() {
let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
format!("new List<string> {{ {} }}", items.join(", "))
} else {
json_to_csharp(val)
};
format!("{pascal_key} = {cs_val}")
})
.collect();
format!("new {} {{ {} }}", type_name, props.join(", "))
}
fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut result = map.clone();
for (key, val) in result.iter_mut() {
if enum_fields.contains_key(key) {
if let Some(s) = val.as_str() {
*val = serde_json::Value::String(s.to_lowercase());
}
}
}
serde_json::Value::Object(result)
}
other => other.clone(),
}
}
fn build_csharp_visitor(
setup_lines: &mut Vec<String>,
class_decls: &mut Vec<String>,
fixture_id: &str,
visitor_spec: &crate::fixture::VisitorSpec,
) -> String {
use heck::ToUpperCamelCase;
let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
setup_lines.push(format!("var {var_name} = new {class_name}();"));
let mut decl = String::new();
decl.push_str(&format!(" private sealed class {class_name} : IHtmlVisitor\n"));
decl.push_str(" {\n");
let all_methods = [
"visit_element_start",
"visit_element_end",
"visit_text",
"visit_link",
"visit_image",
"visit_heading",
"visit_code_block",
"visit_code_inline",
"visit_list_item",
"visit_list_start",
"visit_list_end",
"visit_table_start",
"visit_table_row",
"visit_table_end",
"visit_blockquote",
"visit_strong",
"visit_emphasis",
"visit_strikethrough",
"visit_underline",
"visit_subscript",
"visit_superscript",
"visit_mark",
"visit_line_break",
"visit_horizontal_rule",
"visit_custom_element",
"visit_definition_list_start",
"visit_definition_term",
"visit_definition_description",
"visit_definition_list_end",
"visit_form",
"visit_input",
"visit_button",
"visit_audio",
"visit_video",
"visit_iframe",
"visit_details",
"visit_summary",
"visit_figure_start",
"visit_figcaption",
"visit_figure_end",
];
for method_name in &all_methods {
if let Some(action) = visitor_spec.callbacks.get(*method_name) {
emit_csharp_visitor_method(&mut decl, method_name, action);
} else {
emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
}
}
decl.push_str(" }\n");
class_decls.push(decl);
var_name
}
fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
let camel_method = method_to_camel(method_name);
let params = match method_name {
"visit_link" => "NodeContext ctx, string href, string text, string title",
"visit_image" => "NodeContext ctx, string src, string alt, string title",
"visit_heading" => "NodeContext ctx, uint level, string text, string id",
"visit_code_block" => "NodeContext ctx, string lang, string code",
"visit_code_inline"
| "visit_strong"
| "visit_emphasis"
| "visit_strikethrough"
| "visit_underline"
| "visit_subscript"
| "visit_superscript"
| "visit_mark"
| "visit_button"
| "visit_summary"
| "visit_figcaption"
| "visit_definition_term"
| "visit_definition_description" => "NodeContext ctx, string text",
"visit_text" => "NodeContext ctx, string text",
"visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
"visit_blockquote" => "NodeContext ctx, string content, ulong depth",
"visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
"visit_custom_element" => "NodeContext ctx, string tagName, string html",
"visit_form" => "NodeContext ctx, string actionUrl, string method",
"visit_input" => "NodeContext ctx, string inputType, string name, string value",
"visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
"visit_details" => "NodeContext ctx, bool isOpen",
"visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
"NodeContext ctx, string output"
}
"visit_list_start" => "NodeContext ctx, bool ordered",
"visit_list_end" => "NodeContext ctx, bool ordered, string output",
"visit_element_start"
| "visit_table_start"
| "visit_definition_list_start"
| "visit_figure_start"
| "visit_line_break"
| "visit_horizontal_rule" => "NodeContext ctx",
_ => "NodeContext ctx",
};
let (action_type, action_value) = match action {
CallbackAction::Skip => ("skip", String::new()),
CallbackAction::Continue => ("continue", String::new()),
CallbackAction::PreserveHtml => ("preserve_html", String::new()),
CallbackAction::Custom { output } => ("custom", escape_csharp(output)),
CallbackAction::CustomTemplate { template } => {
let camel = snake_case_template_to_camel(template);
("custom_template", escape_csharp(&camel))
}
};
let rendered = crate::template_env::render(
"csharp/visitor_method.jinja",
minijinja::context! {
camel_method => camel_method,
params => params,
action_type => action_type,
action_value => action_value,
},
);
let _ = write!(decl, "{}", rendered);
}
fn method_to_camel(snake: &str) -> String {
use heck::ToUpperCamelCase;
snake.to_upper_camel_case()
}
fn snake_case_template_to_camel(template: &str) -> String {
use heck::ToLowerCamelCase;
let mut out = String::with_capacity(template.len());
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
if c == '{' {
let mut name = String::new();
while let Some(&nc) = chars.peek() {
if nc == '}' {
chars.next();
break;
}
name.push(nc);
chars.next();
}
out.push('{');
out.push_str(&name.to_lower_camel_case());
out.push('}');
} else {
out.push(c);
}
}
out
}
fn build_csharp_method_call(
result_var: &str,
method_name: &str,
args: Option<&serde_json::Value>,
class_name: &str,
) -> String {
match method_name {
"root_child_count" => format!("{result_var}.RootNode.ChildCount"),
"root_node_type" => format!("{result_var}.RootNode.Kind"),
"named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
"has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
"error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
"tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
"contains_node_type" => {
let node_type = args
.and_then(|a| a.get("node_type"))
.and_then(|v| v.as_str())
.unwrap_or("");
format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
}
"find_nodes_by_type" => {
let node_type = args
.and_then(|a| a.get("node_type"))
.and_then(|v| v.as_str())
.unwrap_or("");
format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
}
"run_query" => {
let query_source = args
.and_then(|a| a.get("query_source"))
.and_then(|v| v.as_str())
.unwrap_or("");
let language = args
.and_then(|a| a.get("language"))
.and_then(|v| v.as_str())
.unwrap_or("");
format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
}
_ => {
use heck::ToUpperCamelCase;
let pascal = method_name.to_upper_camel_case();
format!("{result_var}.{pascal}()")
}
}
}
fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
if fixture.is_http_test() {
return false;
}
let call_config = e2e_config.resolve_call(fixture.call.as_deref());
let cs_override = call_config
.overrides
.get("csharp")
.or_else(|| e2e_config.call.overrides.get("csharp"));
if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
return true;
}
cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
}
fn classify_bytes_value_csharp(s: &str) -> String {
if let Some(first) = s.chars().next() {
if first.is_ascii_alphanumeric() || first == '_' {
if let Some(slash_pos) = s.find('/') {
if slash_pos > 0 {
let after_slash = &s[slash_pos + 1..];
if after_slash.contains('.') && !after_slash.is_empty() {
return format!("System.IO.File.ReadAllBytes(\"{}\")", s);
}
}
}
}
}
if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
return format!("System.Text.Encoding.UTF8.GetBytes(\"{}\")", escape_csharp(s));
}
format!("System.Convert.FromBase64String(\"{}\")", s)
}