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,
_type_defs: &[alef_core::ir::TypeDef],
) -> 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,
});
let needs_mock_server = groups
.iter()
.flat_map(|g| g.fixtures.iter())
.any(|f| f.needs_mock_server());
files.push(GeneratedFile {
path: output_base.join("TestSetup.cs"),
content: render_test_setup(needs_mock_server),
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(needs_mock_server: bool) -> String {
let mut out = String::new();
out.push_str(&hash::header(CommentStyle::DoubleSlash));
out.push_str("using System;\n");
out.push_str("using System.IO;\n");
if needs_mock_server {
out.push_str("using System.Diagnostics;\n");
}
out.push_str("using System.Runtime.CompilerServices;\n\n");
out.push_str("namespace Kreuzberg.E2eTests;\n\n");
out.push_str("internal static class TestSetup\n");
out.push_str("{\n");
if needs_mock_server {
out.push_str(" private static Process? _mockServer;\n\n");
}
out.push_str(" [ModuleInitializer]\n");
out.push_str(" internal static void Init()\n");
out.push_str(" {\n");
out.push_str(" // Walk up from the assembly directory until we find the repo root\n");
out.push_str(" // (the directory containing test_documents/) so that fixture paths\n");
out.push_str(" // like \"docx/fake.docx\" resolve regardless of where dotnet test\n");
out.push_str(" // launched the runner from.\n");
out.push_str(" var dir = new DirectoryInfo(AppContext.BaseDirectory);\n");
out.push_str(" DirectoryInfo? repoRoot = null;\n");
out.push_str(" while (dir != null)\n");
out.push_str(" {\n");
out.push_str(" var candidate = Path.Combine(dir.FullName, \"test_documents\");\n");
out.push_str(" if (Directory.Exists(candidate))\n");
out.push_str(" {\n");
out.push_str(" repoRoot = dir;\n");
out.push_str(" Directory.SetCurrentDirectory(candidate);\n");
out.push_str(" break;\n");
out.push_str(" }\n");
out.push_str(" dir = dir.Parent;\n");
out.push_str(" }\n");
if needs_mock_server {
out.push('\n');
out.push_str(" // Spawn the mock-server binary before any test loads, mirroring the\n");
out.push_str(" // Ruby spec_helper / Python conftest pattern. Honors a pre-set\n");
out.push_str(" // MOCK_SERVER_URL (e.g. set by `task` or CI) by skipping the spawn.\n");
out.push_str(" // Without this, every fixture-bound test failed with\n");
out.push_str(" // `<Lib>Exception : builder error` because reqwest rejected the\n");
out.push_str(" // relative URL produced by `\"\" + \"/fixtures/<id>\"`.\n");
out.push_str(" var preset = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\");\n");
out.push_str(" if (!string.IsNullOrEmpty(preset))\n");
out.push_str(" {\n");
out.push_str(" return;\n");
out.push_str(" }\n");
out.push_str(" if (repoRoot == null)\n");
out.push_str(" {\n");
out.push_str(" throw new InvalidOperationException(\"TestSetup: could not locate repo root (test_documents/ not found)\");\n");
out.push_str(" }\n");
out.push_str(" var bin = Path.Combine(\n");
out.push_str(" repoRoot.FullName,\n");
out.push_str(" \"e2e\", \"rust\", \"target\", \"release\", \"mock-server\");\n");
out.push_str(" if (OperatingSystem.IsWindows())\n");
out.push_str(" {\n");
out.push_str(" bin += \".exe\";\n");
out.push_str(" }\n");
out.push_str(" var fixturesDir = Path.Combine(repoRoot.FullName, \"fixtures\");\n");
out.push_str(" if (!File.Exists(bin))\n");
out.push_str(" {\n");
out.push_str(" throw new InvalidOperationException(\n");
out.push_str(" $\"TestSetup: mock-server binary not found at {bin} — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release\");\n");
out.push_str(" }\n");
out.push_str(" var psi = new ProcessStartInfo\n");
out.push_str(" {\n");
out.push_str(" FileName = bin,\n");
out.push_str(" Arguments = $\"\\\"{fixturesDir}\\\"\",\n");
out.push_str(" RedirectStandardInput = true,\n");
out.push_str(" RedirectStandardOutput = true,\n");
out.push_str(" RedirectStandardError = true,\n");
out.push_str(" UseShellExecute = false,\n");
out.push_str(" };\n");
out.push_str(" _mockServer = Process.Start(psi)\n");
out.push_str(
" ?? throw new InvalidOperationException(\"TestSetup: failed to start mock-server\");\n",
);
out.push_str(" // The mock-server prints `MOCK_SERVER_URL=<url>` as its first stdout\n");
out.push_str(" // line, then `mock-server: loaded <N> routes from <dir>` etc. Read\n");
out.push_str(" // until we see the URL line so it can race against startup.\n");
out.push_str(" string? url = null;\n");
out.push_str(" for (int i = 0; i < 16; i++)\n");
out.push_str(" {\n");
out.push_str(" var line = _mockServer.StandardOutput.ReadLine();\n");
out.push_str(" if (line == null)\n");
out.push_str(" {\n");
out.push_str(" break;\n");
out.push_str(" }\n");
out.push_str(" const string prefix = \"MOCK_SERVER_URL=\";\n");
out.push_str(" if (line.StartsWith(prefix, StringComparison.Ordinal))\n");
out.push_str(" {\n");
out.push_str(" url = line.Substring(prefix.Length).Trim();\n");
out.push_str(" break;\n");
out.push_str(" }\n");
out.push_str(" }\n");
out.push_str(" if (string.IsNullOrEmpty(url))\n");
out.push_str(" {\n");
out.push_str(" try { _mockServer.Kill(true); } catch { }\n");
out.push_str(" throw new InvalidOperationException(\"TestSetup: mock-server did not emit MOCK_SERVER_URL\");\n");
out.push_str(" }\n");
out.push_str(" Environment.SetEnvironmentVariable(\"MOCK_SERVER_URL\", url);\n");
out.push_str(" // TCP-readiness probe: ensure axum::serve is accepting before tests start.\n");
out.push_str(" // The mock-server binds the TcpListener synchronously then prints the URL\n");
out.push_str(" // before tokio::spawn(axum::serve(...)) is polled, so under xUnit\n");
out.push_str(" // class-parallel default tests can race startup. Poll-connect (max 5s,\n");
out.push_str(" // 50ms backoff) until success.\n");
out.push_str(" var healthUri = new System.Uri(url);\n");
out.push_str(" var deadline = System.Diagnostics.Stopwatch.StartNew();\n");
out.push_str(" while (deadline.ElapsedMilliseconds < 5000)\n");
out.push_str(" {\n");
out.push_str(" try\n");
out.push_str(" {\n");
out.push_str(" using var probe = new System.Net.Sockets.TcpClient();\n");
out.push_str(" var task = probe.ConnectAsync(healthUri.Host, healthUri.Port);\n");
out.push_str(" if (task.Wait(100) && probe.Connected) { break; }\n");
out.push_str(" }\n");
out.push_str(" catch (System.Exception) { }\n");
out.push_str(" System.Threading.Thread.Sleep(50);\n");
out.push_str(" }\n");
out.push_str(" // Drain stdout/stderr so the child does not block on a full pipe.\n");
out.push_str(" var server = _mockServer;\n");
out.push_str(" var stdoutThread = new System.Threading.Thread(() =>\n");
out.push_str(" {\n");
out.push_str(" try { server.StandardOutput.ReadToEnd(); } catch { }\n");
out.push_str(" }) { IsBackground = true };\n");
out.push_str(" stdoutThread.Start();\n");
out.push_str(" var stderrThread = new System.Threading.Thread(() =>\n");
out.push_str(" {\n");
out.push_str(" try { server.StandardError.ReadToEnd(); } catch { }\n");
out.push_str(" }) { IsBackground = true };\n");
out.push_str(" stderrThread.Start();\n");
out.push_str(" // Tear the child down on assembly unload / process exit by closing\n");
out.push_str(" // its stdin (the mock-server treats stdin EOF as a shutdown signal).\n");
out.push_str(" AppDomain.CurrentDomain.ProcessExit += (_, _) =>\n");
out.push_str(" {\n");
out.push_str(" try { _mockServer.StandardInput.Close(); } catch { }\n");
out.push_str(" try { if (!_mockServer.WaitForExit(2000)) { _mockServer.Kill(true); } } catch { }\n");
out.push_str(" };\n");
}
out.push_str(" }\n");
out.push_str("}\n");
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 raw_function_name = cs_overrides
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| call_config.function.clone());
if raw_function_name == "chat_stream" {
render_chat_stream_test_method(
out,
fixture,
class_name,
call_config,
cs_overrides,
e2e_config,
enum_fields,
nested_types,
exception_class,
);
return;
}
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 per_call_result_is_bytes = call_config.result_is_bytes || cs_overrides.is_some_and(|o| o.result_is_bytes);
let effective_result_is_simple = result_is_simple || per_call_result_is_simple || per_call_result_is_bytes;
let effective_result_is_bytes = per_call_result_is_bytes;
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 top_level_options_via = e2e_config
.call
.overrides
.get("csharp")
.and_then(|o| o.options_via.as_deref());
let effective_options_via = cs_overrides
.and_then(|o| o.options_via.as_deref())
.or(top_level_options_via);
let (mut setup_lines, args_str) = build_args_and_setup(
&fixture.input,
args,
class_name,
effective_options_type,
effective_options_via,
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;
let is_live_smoke = fixture.mock_response.is_none()
&& fixture.http.is_none()
&& fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref()).is_some();
if is_live_smoke {
let api_key_var = fixture
.env
.as_ref()
.and_then(|e| e.api_key_var.as_deref())
.unwrap_or("");
client_factory_setup.push_str(&format!(
" var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
));
client_factory_setup.push_str(" if (string.IsNullOrEmpty(apiKey)) { return; }\n");
client_factory_setup.push_str(&format!(
" var client = {class_name}.{factory_name}(apiKey, null, null, null, null);\n"
));
} else {
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 effective_enum_fields: std::collections::HashSet<String> = e2e_config.fields_enum.clone();
for k in enum_fields.keys() {
effective_enum_fields.insert(k.clone());
}
if let Some(o) = cs_overrides {
for k in o.enum_fields.keys() {
effective_enum_fields.insert(k.clone());
}
}
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,
effective_result_is_bytes,
&effective_enum_fields,
);
}
}
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');
}
}
#[allow(clippy::too_many_arguments)]
fn render_chat_stream_test_method(
out: &mut String,
fixture: &Fixture,
class_name: &str,
call_config: &crate::config::CallConfig,
cs_overrides: Option<&crate::config::CallOverride>,
e2e_config: &E2eConfig,
enum_fields: &HashMap<String, String>,
nested_types: &HashMap<String, String>,
exception_class: &str,
) {
let method_name = fixture.id.to_upper_camel_case();
let description = &fixture.description;
let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
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 function_name = effective_function_name.as_str();
let args = call_config.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 top_level_options_via = e2e_config
.call
.overrides
.get("csharp")
.and_then(|o| o.options_via.as_deref());
let effective_options_via = cs_overrides
.and_then(|o| o.options_via.as_deref())
.or(top_level_options_via);
let (setup_lines, args_str) = build_args_and_setup(
&fixture.input,
args,
class_name,
effective_options_type,
effective_options_via,
enum_fields,
nested_types,
&fixture.id,
);
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 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;
let is_live_smoke = fixture.mock_response.is_none()
&& fixture.http.is_none()
&& fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref()).is_some();
if is_live_smoke {
let api_key_var = fixture
.env
.as_ref()
.and_then(|e| e.api_key_var.as_deref())
.unwrap_or("");
client_factory_setup.push_str(&format!(
" var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
));
client_factory_setup.push_str(" if (string.IsNullOrEmpty(apiKey)) { return; }\n");
client_factory_setup.push_str(&format!(
" var client = {class_name}.{factory_name}(apiKey, null, null, null, null);\n"
));
} else {
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_target = if client_factory.is_some() { "client" } else { class_name };
let call_expr = format!("{call_target}.{function_name}({args_str})");
let mut needs_finish_reason = false;
let mut needs_tool_calls_json = false;
let mut needs_tool_calls_0_function_name = false;
let mut needs_total_tokens = false;
for a in &fixture.assertions {
if let Some(f) = a.field.as_deref() {
match f {
"finish_reason" => needs_finish_reason = true,
"tool_calls" => needs_tool_calls_json = true,
"tool_calls[0].function.name" => needs_tool_calls_0_function_name = true,
"usage.total_tokens" => needs_total_tokens = true,
_ => {}
}
}
}
let mut body = String::new();
let _ = writeln!(body, " [Fact]");
let _ = writeln!(body, " public async Task Test_{method_name}()");
let _ = writeln!(body, " {{");
let _ = writeln!(body, " // {description}");
if !client_factory_setup.is_empty() {
body.push_str(&client_factory_setup);
}
for line in &setup_lines {
let _ = writeln!(body, " {line}");
}
if expects_error {
let _ = writeln!(
body,
" await Assert.ThrowsAnyAsync<{exception_class}>(async () => {{"
);
let _ = writeln!(body, " await foreach (var _chunk in {call_expr}) {{ }}");
body.push_str(" });\n");
body.push_str(" }\n");
for line in body.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
return;
}
body.push_str(" var chunks = new List<ChatCompletionChunk>();\n");
body.push_str(" var streamContent = new System.Text.StringBuilder();\n");
body.push_str(" var streamComplete = false;\n");
if needs_finish_reason {
body.push_str(" string? lastFinishReason = null;\n");
}
if needs_tool_calls_json {
body.push_str(" string? toolCallsJson = null;\n");
}
if needs_tool_calls_0_function_name {
body.push_str(" string? toolCalls0FunctionName = null;\n");
}
if needs_total_tokens {
body.push_str(" long? totalTokens = null;\n");
}
let _ = writeln!(body, " await foreach (var chunk in {call_expr})");
body.push_str(" {\n");
body.push_str(" chunks.Add(chunk);\n");
body.push_str(
" var choice = chunk.Choices != null && chunk.Choices.Count > 0 ? chunk.Choices[0] : null;\n",
);
body.push_str(" if (choice != null)\n");
body.push_str(" {\n");
body.push_str(" var delta = choice.Delta;\n");
body.push_str(" if (delta != null && !string.IsNullOrEmpty(delta.Content))\n");
body.push_str(" {\n");
body.push_str(" streamContent.Append(delta.Content);\n");
body.push_str(" }\n");
if needs_finish_reason {
body.push_str(" if (choice.FinishReason != null)\n");
body.push_str(" {\n");
body.push_str(" lastFinishReason = choice.FinishReason?.ToString()?.ToLower();\n");
body.push_str(" }\n");
}
if needs_tool_calls_json || needs_tool_calls_0_function_name {
body.push_str(" var tcs = delta?.ToolCalls;\n");
body.push_str(" if (tcs != null && tcs.Count > 0)\n");
body.push_str(" {\n");
if needs_tool_calls_json {
body.push_str(
" toolCallsJson ??= JsonSerializer.Serialize(tcs.Select(tc => new { function = new { name = tc.Function?.Name } }));\n",
);
}
if needs_tool_calls_0_function_name {
body.push_str(" toolCalls0FunctionName ??= tcs[0].Function?.Name;\n");
}
body.push_str(" }\n");
}
body.push_str(" }\n");
if needs_total_tokens {
body.push_str(" if (chunk.Usage != null)\n");
body.push_str(" {\n");
body.push_str(" totalTokens = chunk.Usage.TotalTokens;\n");
body.push_str(" }\n");
}
body.push_str(" }\n");
body.push_str(" streamComplete = true;\n");
let mut had_explicit_complete = false;
for assertion in &fixture.assertions {
if assertion.field.as_deref() == Some("stream_complete") {
had_explicit_complete = true;
}
emit_chat_stream_assertion(&mut body, assertion);
}
if !had_explicit_complete {
body.push_str(" Assert.True(streamComplete);\n");
}
body.push_str(" }\n");
for line in body.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
fn emit_chat_stream_assertion(out: &mut String, assertion: &Assertion) {
let atype = assertion.assertion_type.as_str();
if atype == "not_error" || atype == "error" {
return;
}
let field = assertion.field.as_deref().unwrap_or("");
enum Kind {
Chunks,
Bool,
Str,
IntTokens,
Json,
Unsupported,
}
let (expr, kind) = match field {
"chunks" => ("chunks", Kind::Chunks),
"stream_content" => ("streamContent.ToString()", Kind::Str),
"stream_complete" => ("streamComplete", Kind::Bool),
"no_chunks_after_done" => ("streamComplete", Kind::Bool),
"finish_reason" => ("lastFinishReason", Kind::Str),
"tool_calls" => ("toolCallsJson", Kind::Json),
"tool_calls[0].function.name" => ("toolCalls0FunctionName", Kind::Str),
"usage.total_tokens" => ("totalTokens", Kind::IntTokens),
_ => ("", Kind::Unsupported),
};
if matches!(kind, Kind::Unsupported) {
let _ = writeln!(
out,
" // skipped: streaming assertion on unsupported field '{field}'"
);
return;
}
match (atype, &kind) {
("count_min", Kind::Chunks) => {
if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
let _ = writeln!(
out,
" Assert.True(chunks.Count >= {n}, \"expected at least {n} chunks\");"
);
}
}
("count_equals", Kind::Chunks) => {
if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
let _ = writeln!(out, " Assert.Equal({n}, chunks.Count);");
}
}
("equals", Kind::Str) => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let _ = writeln!(out, " Assert.Equal({cs_val}, {expr});");
}
}
("contains", Kind::Str) => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let _ = writeln!(out, " Assert.Contains({cs_val}, {expr} ?? string.Empty);");
}
}
("not_empty", Kind::Str) => {
let _ = writeln!(out, " Assert.False(string.IsNullOrEmpty({expr}));");
}
("not_empty", Kind::Json) => {
let _ = writeln!(out, " Assert.NotNull({expr});");
}
("is_empty", Kind::Str) => {
let _ = writeln!(out, " Assert.True(string.IsNullOrEmpty({expr}));");
}
("is_true", Kind::Bool) => {
let _ = writeln!(out, " Assert.True({expr});");
}
("is_false", Kind::Bool) => {
let _ = writeln!(out, " Assert.False({expr});");
}
("greater_than_or_equal", Kind::IntTokens) => {
if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
let _ = writeln!(out, " Assert.True({expr} >= {n}, \"expected >= {n}\");");
}
}
("equals", Kind::IntTokens) => {
if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
let _ = writeln!(out, " Assert.Equal((long?){n}, {expr});");
}
}
_ => {
let _ = writeln!(
out,
" // skipped: streaming assertion '{atype}' on field '{field}' not supported"
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn build_args_and_setup(
input: &serde_json::Value,
args: &[crate::config::ArgMapping],
class_name: &str,
options_type: Option<&str>,
options_via: 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 options_via == Some("from_json")
&& let Some(opts_type) = options_type
{
let sorted = sort_discriminator_first(v.clone());
let json_str = serde_json::to_string(&sorted).unwrap_or_default();
let escaped = escape_csharp(&json_str);
parts.push(format!("{opts_type}.FromJson(\"{escaped}\")",));
continue;
}
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,
result_is_bytes: bool,
fields_enum: &std::collections::HashSet<String>,
) {
if result_is_bytes {
match assertion.assertion_type.as_str() {
"not_empty" => {
let _ = writeln!(out, " Assert.NotEmpty({result_var});");
return;
}
"is_empty" => {
let _ = writeln!(out, " Assert.Empty({result_var});");
return;
}
"count_equals" | "length_equals" => {
if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
let _ = writeln!(out, " Assert.Equal({n}, {result_var}.Length);");
}
return;
}
"count_min" | "length_min" => {
if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
let _ = writeln!(out, " Assert.True({result_var}.Length >= {n});");
}
return;
}
_ => {
let _ = writeln!(
out,
" // skipped: assertion type '{}' not supported on byte[] result",
assertion.assertion_type
);
return;
}
}
}
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()")
};
let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
let resolved = field_resolver.resolve(f);
fields_enum.contains(f) || fields_enum.contains(resolved)
});
match assertion.assertion_type.as_str() {
"equals" => {
if let Some(expected) = &assertion.value {
if field_is_enum && expected.is_string() {
let s_lower = expected.as_str().map(|s| s.to_lowercase()).unwrap_or_default();
let _ = writeln!(
out,
" Assert.Equal(\"{}\", {field_expr} == null ? null : JsonNamingPolicy.SnakeCaseLower.ConvertName({field_expr}.ToString()!));",
escape_csharp(&s_lower)
);
return;
}
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)
}