1use crate::config::E2eConfig;
7use crate::escape::{escape_csharp, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::ResolvedCrateConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions as tv;
14use anyhow::Result;
15use heck::ToUpperCamelCase;
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::hash::{Hash, Hasher};
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22use super::client;
23
24pub struct CSharpCodegen;
26
27impl E2eCodegen for CSharpCodegen {
28 fn generate(
29 &self,
30 groups: &[FixtureGroup],
31 e2e_config: &E2eConfig,
32 config: &ResolvedCrateConfig,
33 _type_defs: &[alef_core::ir::TypeDef],
34 ) -> Result<Vec<GeneratedFile>> {
35 let lang = self.language_name();
36 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
37
38 let mut files = Vec::new();
39
40 let call = &e2e_config.call;
42 let overrides = call.overrides.get(lang);
43 let function_name = overrides
44 .and_then(|o| o.function.as_ref())
45 .cloned()
46 .unwrap_or_else(|| call.function.to_upper_camel_case());
47 let class_name = overrides
48 .and_then(|o| o.class.as_ref())
49 .cloned()
50 .unwrap_or_else(|| format!("{}Lib", config.name.to_upper_camel_case()));
51 let exception_class = format!("{}Exception", config.name.to_upper_camel_case());
53 let namespace = overrides
54 .and_then(|o| o.module.as_ref())
55 .cloned()
56 .or_else(|| config.csharp.as_ref().and_then(|cs| cs.namespace.clone()))
57 .unwrap_or_else(|| {
58 if call.module.is_empty() {
59 "Kreuzberg".to_string()
60 } else {
61 call.module.to_upper_camel_case()
62 }
63 });
64 let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
65 let result_var = &call.result_var;
66 let is_async = call.r#async;
67
68 let cs_pkg = e2e_config.resolve_package("csharp");
70 let pkg_name = cs_pkg
71 .as_ref()
72 .and_then(|p| p.name.as_ref())
73 .cloned()
74 .unwrap_or_else(|| config.name.to_upper_camel_case());
75 let pkg_path = cs_pkg
77 .as_ref()
78 .and_then(|p| p.path.as_ref())
79 .cloned()
80 .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}/{pkg_name}.csproj"));
81 let pkg_version = cs_pkg
82 .as_ref()
83 .and_then(|p| p.version.as_ref())
84 .cloned()
85 .or_else(|| config.resolved_version())
86 .unwrap_or_else(|| "0.1.0".to_string());
87
88 let csproj_name = format!("{pkg_name}.E2eTests.csproj");
91 files.push(GeneratedFile {
92 path: output_base.join(&csproj_name),
93 content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
94 generated_header: false,
95 });
96
97 let needs_mock_server = groups
105 .iter()
106 .flat_map(|g| g.fixtures.iter())
107 .any(|f| f.needs_mock_server());
108
109 files.push(GeneratedFile {
114 path: output_base.join("TestSetup.cs"),
115 content: render_test_setup(needs_mock_server, &e2e_config.test_documents_dir),
116 generated_header: true,
117 });
118
119 let tests_base = output_base.join("tests");
121 let field_resolver = FieldResolver::new(
122 &e2e_config.fields,
123 &e2e_config.fields_optional,
124 &e2e_config.result_fields,
125 &e2e_config.fields_array,
126 &std::collections::HashSet::new(),
127 );
128
129 static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
131 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
132
133 let mut effective_nested_types = default_csharp_nested_types();
135 if let Some(overrides_map) = overrides.map(|o| &o.nested_types) {
136 effective_nested_types.extend(overrides_map.clone());
137 }
138
139 for group in groups {
140 let active: Vec<&Fixture> = group
141 .fixtures
142 .iter()
143 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
144 .collect();
145
146 if active.is_empty() {
147 continue;
148 }
149
150 let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
151 let filename = format!("{test_class}.cs");
152 let content = render_test_file(
153 &group.category,
154 &active,
155 &namespace,
156 &class_name,
157 &function_name,
158 &exception_class,
159 result_var,
160 &test_class,
161 &e2e_config.call.args,
162 &field_resolver,
163 result_is_simple,
164 is_async,
165 e2e_config,
166 enum_fields,
167 &effective_nested_types,
168 );
169 files.push(GeneratedFile {
170 path: tests_base.join(filename),
171 content,
172 generated_header: true,
173 });
174 }
175
176 Ok(files)
177 }
178
179 fn language_name(&self) -> &'static str {
180 "csharp"
181 }
182}
183
184fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
189 let pkg_ref = match dep_mode {
190 crate::config::DependencyMode::Registry => {
191 format!(" <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
192 }
193 crate::config::DependencyMode::Local => {
194 format!(" <ProjectReference Include=\"{pkg_path}\" />")
195 }
196 };
197 crate::template_env::render(
198 "csharp/csproj.jinja",
199 minijinja::context! {
200 pkg_ref => pkg_ref,
201 microsoft_net_test_sdk_version => tv::nuget::MICROSOFT_NET_TEST_SDK,
202 xunit_version => tv::nuget::XUNIT,
203 xunit_runner_version => tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
204 },
205 )
206}
207
208fn render_test_setup(needs_mock_server: bool, test_documents_dir: &str) -> String {
209 let mut out = String::new();
210 out.push_str(&hash::header(CommentStyle::DoubleSlash));
211 out.push_str("using System;\n");
212 out.push_str("using System.IO;\n");
213 if needs_mock_server {
214 out.push_str("using System.Diagnostics;\n");
215 }
216 out.push_str("using System.Runtime.CompilerServices;\n\n");
217 out.push_str("namespace Kreuzberg.E2eTests;\n\n");
218 out.push_str("internal static class TestSetup\n");
219 out.push_str("{\n");
220 if needs_mock_server {
221 out.push_str(" private static Process? _mockServer;\n\n");
222 }
223 out.push_str(" [ModuleInitializer]\n");
224 out.push_str(" internal static void Init()\n");
225 out.push_str(" {\n");
226 let _ = writeln!(
227 out,
228 " // Walk up from the assembly directory until we find the repo root"
229 );
230 let _ = writeln!(
231 out,
232 " // (the directory containing {test_documents_dir}/) so that fixture paths"
233 );
234 out.push_str(" // like \"docx/fake.docx\" resolve regardless of where dotnet test\n");
235 out.push_str(" // launched the runner from.\n");
236 out.push_str(" var dir = new DirectoryInfo(AppContext.BaseDirectory);\n");
237 out.push_str(" DirectoryInfo? repoRoot = null;\n");
238 out.push_str(" while (dir != null)\n");
239 out.push_str(" {\n");
240 let _ = writeln!(
241 out,
242 " var candidate = Path.Combine(dir.FullName, \"{test_documents_dir}\");"
243 );
244 out.push_str(" if (Directory.Exists(candidate))\n");
245 out.push_str(" {\n");
246 out.push_str(" repoRoot = dir;\n");
247 out.push_str(" Directory.SetCurrentDirectory(candidate);\n");
248 out.push_str(" break;\n");
249 out.push_str(" }\n");
250 out.push_str(" dir = dir.Parent;\n");
251 out.push_str(" }\n");
252 if needs_mock_server {
253 out.push('\n');
254 out.push_str(" // Spawn the mock-server binary before any test loads, mirroring the\n");
255 out.push_str(" // Ruby spec_helper / Python conftest pattern. Honors a pre-set\n");
256 out.push_str(" // MOCK_SERVER_URL (e.g. set by `task` or CI) by skipping the spawn.\n");
257 out.push_str(" // Without this, every fixture-bound test failed with\n");
258 out.push_str(" // `<Lib>Exception : builder error` because reqwest rejected the\n");
259 out.push_str(" // relative URL produced by `\"\" + \"/fixtures/<id>\"`.\n");
260 out.push_str(" var preset = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\");\n");
261 out.push_str(" if (!string.IsNullOrEmpty(preset))\n");
262 out.push_str(" {\n");
263 out.push_str(" return;\n");
264 out.push_str(" }\n");
265 out.push_str(" if (repoRoot == null)\n");
266 out.push_str(" {\n");
267 let _ = writeln!(
268 out,
269 " throw new InvalidOperationException(\"TestSetup: could not locate repo root ({test_documents_dir}/ not found)\");"
270 );
271 out.push_str(" }\n");
272 out.push_str(" var bin = Path.Combine(\n");
273 out.push_str(" repoRoot.FullName,\n");
274 out.push_str(" \"e2e\", \"rust\", \"target\", \"release\", \"mock-server\");\n");
275 out.push_str(" if (OperatingSystem.IsWindows())\n");
276 out.push_str(" {\n");
277 out.push_str(" bin += \".exe\";\n");
278 out.push_str(" }\n");
279 out.push_str(" var fixturesDir = Path.Combine(repoRoot.FullName, \"fixtures\");\n");
280 out.push_str(" if (!File.Exists(bin))\n");
281 out.push_str(" {\n");
282 out.push_str(" throw new InvalidOperationException(\n");
283 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");
284 out.push_str(" }\n");
285 out.push_str(" var psi = new ProcessStartInfo\n");
286 out.push_str(" {\n");
287 out.push_str(" FileName = bin,\n");
288 out.push_str(" Arguments = $\"\\\"{fixturesDir}\\\"\",\n");
289 out.push_str(" RedirectStandardInput = true,\n");
290 out.push_str(" RedirectStandardOutput = true,\n");
291 out.push_str(" RedirectStandardError = true,\n");
292 out.push_str(" UseShellExecute = false,\n");
293 out.push_str(" };\n");
294 out.push_str(" _mockServer = Process.Start(psi)\n");
295 out.push_str(
296 " ?? throw new InvalidOperationException(\"TestSetup: failed to start mock-server\");\n",
297 );
298 out.push_str(" // The mock-server prints MOCK_SERVER_URL=<url>, then optionally\n");
299 out.push_str(" // MOCK_SERVERS={...} for host-root fixtures. Read up to 16 lines.\n");
300 out.push_str(" string? url = null;\n");
301 out.push_str(" for (int i = 0; i < 16; i++)\n");
302 out.push_str(" {\n");
303 out.push_str(" var line = _mockServer.StandardOutput.ReadLine();\n");
304 out.push_str(" if (line == null)\n");
305 out.push_str(" {\n");
306 out.push_str(" break;\n");
307 out.push_str(" }\n");
308 out.push_str(" const string urlPrefix = \"MOCK_SERVER_URL=\";\n");
309 out.push_str(" const string serversPrefix = \"MOCK_SERVERS=\";\n");
310 out.push_str(" if (line.StartsWith(urlPrefix, StringComparison.Ordinal))\n");
311 out.push_str(" {\n");
312 out.push_str(" url = line.Substring(urlPrefix.Length).Trim();\n");
313 out.push_str(" }\n");
314 out.push_str(" else if (line.StartsWith(serversPrefix, StringComparison.Ordinal))\n");
315 out.push_str(" {\n");
316 out.push_str(" var jsonVal = line.Substring(serversPrefix.Length).Trim();\n");
317 out.push_str(" Environment.SetEnvironmentVariable(\"MOCK_SERVERS\", jsonVal);\n");
318 out.push_str(" // Parse JSON map and set per-fixture env vars (MOCK_SERVER_<FIXTURE_ID>).\n");
319 out.push_str(" var matches = System.Text.RegularExpressions.Regex.Matches(\n");
320 out.push_str(" jsonVal, \"\\\"([^\\\"]+)\\\":\\\"([^\\\"]+)\\\"\");\n");
321 out.push_str(" foreach (System.Text.RegularExpressions.Match m in matches)\n");
322 out.push_str(" {\n");
323 out.push_str(" Environment.SetEnvironmentVariable(\n");
324 out.push_str(" \"MOCK_SERVER_\" + m.Groups[1].Value.ToUpperInvariant(),\n");
325 out.push_str(" m.Groups[2].Value);\n");
326 out.push_str(" }\n");
327 out.push_str(" break;\n");
328 out.push_str(" }\n");
329 out.push_str(" else if (url != null)\n");
330 out.push_str(" {\n");
331 out.push_str(" break;\n");
332 out.push_str(" }\n");
333 out.push_str(" }\n");
334 out.push_str(" if (string.IsNullOrEmpty(url))\n");
335 out.push_str(" {\n");
336 out.push_str(" try { _mockServer.Kill(true); } catch { }\n");
337 out.push_str(" throw new InvalidOperationException(\"TestSetup: mock-server did not emit MOCK_SERVER_URL\");\n");
338 out.push_str(" }\n");
339 out.push_str(" Environment.SetEnvironmentVariable(\"MOCK_SERVER_URL\", url);\n");
340 out.push_str(" // TCP-readiness probe: ensure axum::serve is accepting before tests start.\n");
341 out.push_str(" // The mock-server binds the TcpListener synchronously then prints the URL\n");
342 out.push_str(" // before tokio::spawn(axum::serve(...)) is polled, so under xUnit\n");
343 out.push_str(" // class-parallel default tests can race startup. Poll-connect (max 5s,\n");
344 out.push_str(" // 50ms backoff) until success.\n");
345 out.push_str(" var healthUri = new System.Uri(url);\n");
346 out.push_str(" var deadline = System.Diagnostics.Stopwatch.StartNew();\n");
347 out.push_str(" while (deadline.ElapsedMilliseconds < 5000)\n");
348 out.push_str(" {\n");
349 out.push_str(" try\n");
350 out.push_str(" {\n");
351 out.push_str(" using var probe = new System.Net.Sockets.TcpClient();\n");
352 out.push_str(" var task = probe.ConnectAsync(healthUri.Host, healthUri.Port);\n");
353 out.push_str(" if (task.Wait(100) && probe.Connected) { break; }\n");
354 out.push_str(" }\n");
355 out.push_str(" catch (System.Exception) { }\n");
356 out.push_str(" System.Threading.Thread.Sleep(50);\n");
357 out.push_str(" }\n");
358 out.push_str(" // Drain stdout/stderr so the child does not block on a full pipe.\n");
359 out.push_str(" var server = _mockServer;\n");
360 out.push_str(" var stdoutThread = new System.Threading.Thread(() =>\n");
361 out.push_str(" {\n");
362 out.push_str(" try { server.StandardOutput.ReadToEnd(); } catch { }\n");
363 out.push_str(" }) { IsBackground = true };\n");
364 out.push_str(" stdoutThread.Start();\n");
365 out.push_str(" var stderrThread = new System.Threading.Thread(() =>\n");
366 out.push_str(" {\n");
367 out.push_str(" try { server.StandardError.ReadToEnd(); } catch { }\n");
368 out.push_str(" }) { IsBackground = true };\n");
369 out.push_str(" stderrThread.Start();\n");
370 out.push_str(" // Tear the child down on assembly unload / process exit by closing\n");
371 out.push_str(" // its stdin (the mock-server treats stdin EOF as a shutdown signal).\n");
372 out.push_str(" AppDomain.CurrentDomain.ProcessExit += (_, _) =>\n");
373 out.push_str(" {\n");
374 out.push_str(" try { _mockServer.StandardInput.Close(); } catch { }\n");
375 out.push_str(" try { if (!_mockServer.WaitForExit(2000)) { _mockServer.Kill(true); } } catch { }\n");
376 out.push_str(" };\n");
377 }
378 out.push_str(" }\n");
379 out.push_str("}\n");
380 out
381}
382
383#[allow(clippy::too_many_arguments)]
384fn render_test_file(
385 category: &str,
386 fixtures: &[&Fixture],
387 namespace: &str,
388 class_name: &str,
389 function_name: &str,
390 exception_class: &str,
391 result_var: &str,
392 test_class: &str,
393 args: &[crate::config::ArgMapping],
394 field_resolver: &FieldResolver,
395 result_is_simple: bool,
396 is_async: bool,
397 e2e_config: &E2eConfig,
398 enum_fields: &HashMap<String, String>,
399 nested_types: &HashMap<String, String>,
400) -> String {
401 let mut using_imports = String::new();
403 using_imports.push_str("using System;\n");
404 using_imports.push_str("using System.Collections.Generic;\n");
405 using_imports.push_str("using System.Linq;\n");
406 using_imports.push_str("using System.Net.Http;\n");
407 using_imports.push_str("using System.Text;\n");
408 using_imports.push_str("using System.Text.Json;\n");
409 using_imports.push_str("using System.Text.Json.Serialization;\n");
410 using_imports.push_str("using System.Threading.Tasks;\n");
411 using_imports.push_str("using Xunit;\n");
412 using_imports.push_str(&format!("using {namespace};\n"));
413 using_imports.push_str(&format!("using static {namespace}.{class_name};\n"));
414
415 let config_options_field = " private static readonly JsonSerializerOptions ConfigOptions = new() { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault };";
417
418 let mut visitor_class_decls: Vec<String> = Vec::new();
422
423 let mut fixtures_body = String::new();
425 for (i, fixture) in fixtures.iter().enumerate() {
426 render_test_method(
427 &mut fixtures_body,
428 &mut visitor_class_decls,
429 fixture,
430 class_name,
431 function_name,
432 exception_class,
433 result_var,
434 args,
435 field_resolver,
436 result_is_simple,
437 is_async,
438 e2e_config,
439 enum_fields,
440 nested_types,
441 );
442 if i + 1 < fixtures.len() {
443 fixtures_body.push('\n');
444 }
445 }
446
447 let mut visitor_classes_str = String::new();
449 for (i, decl) in visitor_class_decls.iter().enumerate() {
450 if i > 0 {
451 visitor_classes_str.push('\n');
452 }
453 visitor_classes_str.push('\n');
454 for line in decl.lines() {
456 visitor_classes_str.push_str(" ");
457 visitor_classes_str.push_str(line);
458 visitor_classes_str.push('\n');
459 }
460 }
461
462 let ctx = minijinja::context! {
463 header => hash::header(CommentStyle::DoubleSlash),
464 using_imports => using_imports,
465 category => category,
466 namespace => namespace,
467 test_class => test_class,
468 config_options_field => config_options_field,
469 fixtures_body => fixtures_body,
470 visitor_class_decls => visitor_classes_str,
471 };
472
473 crate::template_env::render("csharp/test_file.jinja", ctx)
474}
475
476struct CSharpTestClientRenderer;
485
486fn to_csharp_http_method(method: &str) -> String {
488 let lower = method.to_ascii_lowercase();
489 let mut chars = lower.chars();
490 match chars.next() {
491 Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
492 None => String::new(),
493 }
494}
495
496const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
500 "content-length",
501 "host",
502 "connection",
503 "expect",
504 "transfer-encoding",
505 "upgrade",
506 "content-type",
509 "content-encoding",
511 "content-language",
512 "content-location",
513 "content-md5",
514 "content-range",
515 "content-disposition",
516];
517
518fn is_csharp_content_header(name: &str) -> bool {
522 matches!(
523 name.to_ascii_lowercase().as_str(),
524 "content-type"
525 | "content-length"
526 | "content-encoding"
527 | "content-language"
528 | "content-location"
529 | "content-md5"
530 | "content-range"
531 | "content-disposition"
532 | "expires"
533 | "last-modified"
534 | "allow"
535 )
536}
537
538impl client::TestClientRenderer for CSharpTestClientRenderer {
539 fn language_name(&self) -> &'static str {
540 "csharp"
541 }
542
543 fn sanitize_test_name(&self, id: &str) -> String {
545 id.to_upper_camel_case()
546 }
547
548 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
551 let escaped_reason = skip_reason.map(escape_csharp);
552 let rendered = crate::template_env::render(
553 "csharp/http_test_open.jinja",
554 minijinja::context! {
555 fn_name => fn_name,
556 description => description,
557 skip_reason => escaped_reason,
558 },
559 );
560 out.push_str(&rendered);
561 }
562
563 fn render_test_close(&self, out: &mut String) {
565 let rendered = crate::template_env::render("csharp/http_test_close.jinja", minijinja::context! {});
566 out.push_str(&rendered);
567 }
568
569 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
574 let method = to_csharp_http_method(ctx.method);
575 let path = escape_csharp(ctx.path);
576
577 out.push_str(" var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";\n");
578 out.push_str(
581 " using var handler = new System.Net.Http.HttpClientHandler { AllowAutoRedirect = false };\n",
582 );
583 out.push_str(" using var client = new System.Net.Http.HttpClient(handler);\n");
584 out.push_str(&format!(" var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}{path}\");\n"));
585
586 if let Some(body) = ctx.body {
588 let content_type = ctx.content_type.unwrap_or("application/json");
589 let json_str = serde_json::to_string(body).unwrap_or_default();
590 let escaped = escape_csharp(&json_str);
591 out.push_str(&format!(" request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");\n"));
592 }
593
594 for (name, value) in ctx.headers {
596 if CSHARP_RESTRICTED_REQUEST_HEADERS.contains(&name.to_lowercase().as_str()) {
597 continue;
598 }
599 let escaped_name = escape_csharp(name);
600 let escaped_value = escape_csharp(value);
601 out.push_str(&format!(
602 " request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");\n"
603 ));
604 }
605
606 if !ctx.cookies.is_empty() {
608 let mut pairs: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
609 pairs.sort();
610 let cookie_header = escape_csharp(&pairs.join("; "));
611 out.push_str(&format!(
612 " request.Headers.Add(\"Cookie\", \"{cookie_header}\");\n"
613 ));
614 }
615
616 out.push_str(" var response = await client.SendAsync(request);\n");
617 }
618
619 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
621 out.push_str(&format!(" Assert.Equal({status}, (int)response.StatusCode);\n"));
622 }
623
624 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
629 let target = if is_csharp_content_header(name) {
630 "response.Content.Headers"
631 } else {
632 "response.Headers"
633 };
634 let escaped_name = escape_csharp(name);
635 match expected {
636 "<<present>>" => {
637 out.push_str(&format!(" Assert.True({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be present\");\n"));
638 }
639 "<<absent>>" => {
640 out.push_str(&format!(" Assert.False({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be absent\");\n"));
641 }
642 "<<uuid>>" => {
643 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"));
645 }
646 literal => {
647 let var_name = format!("hdr{}", sanitize_ident(name));
650 let escaped_value = escape_csharp(literal);
651 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"));
652 }
653 }
654 }
655
656 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
660 match expected {
661 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
662 let json_str = serde_json::to_string(expected).unwrap_or_default();
663 let escaped = escape_csharp(&json_str);
664 out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
665 out.push_str(" var body = JsonDocument.Parse(bodyText).RootElement;\n");
666 out.push_str(&format!(
667 " var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;\n"
668 ));
669 out.push_str(" Assert.Equal(expectedBody.GetRawText(), body.GetRawText());\n");
670 }
671 serde_json::Value::String(s) => {
672 let escaped = escape_csharp(s);
673 out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
674 out.push_str(&format!(" Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
675 }
676 other => {
677 let escaped = escape_csharp(&other.to_string());
678 out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
679 out.push_str(&format!(" Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
680 }
681 }
682 }
683
684 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
689 if let Some(obj) = expected.as_object() {
690 out.push_str(" var partialBodyText = await response.Content.ReadAsStringAsync();\n");
691 out.push_str(" var partialBody = JsonDocument.Parse(partialBodyText).RootElement;\n");
692 for (key, val) in obj {
693 let escaped_key = escape_csharp(key);
694 let json_str = serde_json::to_string(val).unwrap_or_default();
695 let escaped_val = escape_csharp(&json_str);
696 let var_name = format!("expected{}", key.to_upper_camel_case());
697 out.push_str(&format!(
698 " var {var_name} = JsonDocument.Parse(\"{escaped_val}\").RootElement;\n"
699 ));
700 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"));
701 }
702 }
703 }
704
705 fn render_assert_validation_errors(
708 &self,
709 out: &mut String,
710 _response_var: &str,
711 errors: &[ValidationErrorExpectation],
712 ) {
713 out.push_str(" var validationBodyText = await response.Content.ReadAsStringAsync();\n");
714 for err in errors {
715 let escaped_msg = escape_csharp(&err.msg);
716 out.push_str(&format!(
717 " Assert.Contains(\"{escaped_msg}\", validationBodyText);\n"
718 ));
719 }
720 }
721}
722
723fn render_http_test_method(out: &mut String, fixture: &Fixture, _http: &HttpFixture) {
726 client::http_call::render_http_test(out, &CSharpTestClientRenderer, fixture);
727}
728
729#[allow(clippy::too_many_arguments)]
730fn render_test_method(
731 out: &mut String,
732 visitor_class_decls: &mut Vec<String>,
733 fixture: &Fixture,
734 class_name: &str,
735 _function_name: &str,
736 exception_class: &str,
737 _result_var: &str,
738 _args: &[crate::config::ArgMapping],
739 field_resolver: &FieldResolver,
740 result_is_simple: bool,
741 _is_async: bool,
742 e2e_config: &E2eConfig,
743 enum_fields: &HashMap<String, String>,
744 nested_types: &HashMap<String, String>,
745) {
746 let method_name = fixture.id.to_upper_camel_case();
747 let description = &fixture.description;
748
749 if let Some(http) = &fixture.http {
751 render_http_test_method(out, fixture, http);
752 return;
753 }
754
755 if fixture.mock_response.is_none() && !fixture_has_csharp_callable(fixture, e2e_config) {
758 let skip_reason =
759 "non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function";
760 let ctx = minijinja::context! {
761 is_skipped => true,
762 skip_reason => skip_reason,
763 description => description,
764 method_name => method_name,
765 };
766 let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
767 out.push_str(&rendered);
768 return;
769 }
770
771 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
772
773 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
777 let lang = "csharp";
778 let cs_overrides = call_config.overrides.get(lang);
779
780 let raw_function_name = cs_overrides
785 .and_then(|o| o.function.as_ref())
786 .cloned()
787 .unwrap_or_else(|| call_config.function.clone());
788 if raw_function_name == "chat_stream" {
789 render_chat_stream_test_method(
790 out,
791 fixture,
792 class_name,
793 call_config,
794 cs_overrides,
795 e2e_config,
796 enum_fields,
797 nested_types,
798 exception_class,
799 );
800 return;
801 }
802
803 let effective_function_name = cs_overrides
804 .and_then(|o| o.function.as_ref())
805 .cloned()
806 .unwrap_or_else(|| call_config.function.to_upper_camel_case());
807 let effective_result_var = &call_config.result_var;
808 let effective_is_async = call_config.r#async;
809 let function_name = effective_function_name.as_str();
810 let result_var = effective_result_var.as_str();
811 let is_async = effective_is_async;
812 let args = call_config.args.as_slice();
813
814 let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
818 let per_call_result_is_bytes = call_config.result_is_bytes || cs_overrides.is_some_and(|o| o.result_is_bytes);
823 let effective_result_is_simple = result_is_simple || per_call_result_is_simple || per_call_result_is_bytes;
824 let effective_result_is_bytes = per_call_result_is_bytes;
825 let returns_void = call_config.returns_void;
826 let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
827 let top_level_options_type = e2e_config
829 .call
830 .overrides
831 .get("csharp")
832 .and_then(|o| o.options_type.as_deref());
833 let effective_options_type = cs_overrides
834 .and_then(|o| o.options_type.as_deref())
835 .or(top_level_options_type);
836
837 let top_level_options_via = e2e_config
844 .call
845 .overrides
846 .get("csharp")
847 .and_then(|o| o.options_via.as_deref());
848 let effective_options_via = cs_overrides
849 .and_then(|o| o.options_via.as_deref())
850 .or(top_level_options_via);
851
852 let (mut setup_lines, args_str) = build_args_and_setup(
853 &fixture.input,
854 args,
855 class_name,
856 effective_options_type,
857 effective_options_via,
858 enum_fields,
859 nested_types,
860 fixture,
861 );
862
863 let mut visitor_arg = String::new();
865 let has_visitor = fixture.visitor.is_some();
866 if let Some(visitor_spec) = &fixture.visitor {
867 visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_class_decls, &fixture.id, visitor_spec);
868 }
869
870 let final_args = if has_visitor && !visitor_arg.is_empty() {
874 let opts_type = effective_options_type.unwrap_or("ConversionOptions");
875 if args_str.contains("JsonSerializer.Deserialize") {
876 setup_lines.push(format!("var options = {args_str};"));
878 setup_lines.push(format!("options.Visitor = {visitor_arg};"));
879 "options".to_string()
880 } else if args_str.ends_with(", null") {
881 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
883 let trimmed = args_str[..args_str.len() - 6].to_string(); format!("{trimmed}, options")
885 } else if args_str.contains(", null,") {
886 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
888 args_str.replace(", null,", ", options,")
889 } else if args_str.is_empty() {
890 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
892 "options".to_string()
893 } else {
894 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
896 format!("{args_str}, options")
897 }
898 } else if extra_args_slice.is_empty() {
899 args_str
900 } else if args_str.is_empty() {
901 extra_args_slice.join(", ")
902 } else {
903 format!("{args_str}, {}", extra_args_slice.join(", "))
904 };
905
906 let effective_function_name = function_name.to_string();
909
910 let return_type = if is_async { "async Task" } else { "void" };
911 let await_kw = if is_async { "await " } else { "" };
912
913 let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
916 e2e_config
917 .call
918 .overrides
919 .get("csharp")
920 .and_then(|o| o.client_factory.as_deref())
921 });
922 let call_target = if client_factory.is_some() {
923 "client".to_string()
924 } else {
925 class_name.to_string()
926 };
927
928 let mut client_factory_setup = String::new();
935 if let Some(factory) = client_factory {
936 let factory_name = factory.to_upper_camel_case();
937 let fixture_id = &fixture.id;
938 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
939 let api_key_var_opt = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
940 let is_live_smoke = !has_mock && api_key_var_opt.is_some();
941 if let Some(api_key_var) = api_key_var_opt.filter(|_| has_mock) {
942 client_factory_setup.push_str(&format!(
943 " var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
944 ));
945 client_factory_setup.push_str(&format!(
946 " var baseUrl = string.IsNullOrEmpty(apiKey)\n ? (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\"\n : null;\n"
947 ));
948 client_factory_setup.push_str(&format!(
949 " Console.WriteLine($\"{fixture_id}: \" + (baseUrl == null ? \"using real API ({api_key_var} is set)\" : \"using mock server ({api_key_var} not set)\"));\n"
950 ));
951 client_factory_setup.push_str(&format!(
952 " var client = {class_name}.{factory_name}(string.IsNullOrEmpty(apiKey) ? \"test-key\" : apiKey, baseUrl, null, null, null);\n"
953 ));
954 } else if let Some(api_key_var) = api_key_var_opt.filter(|_| is_live_smoke) {
955 client_factory_setup.push_str(&format!(
956 " var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
957 ));
958 client_factory_setup.push_str(" if (string.IsNullOrEmpty(apiKey)) { return; }\n");
959 client_factory_setup.push_str(&format!(
960 " var client = {class_name}.{factory_name}(apiKey, null, null, null, null);\n"
961 ));
962 } else if fixture.has_host_root_route() {
963 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
964 client_factory_setup.push_str(&format!(
965 " var _perFixtureUrl = System.Environment.GetEnvironmentVariable(\"{env_key}\");\n"
966 ));
967 client_factory_setup.push_str(&format!(" var baseUrl = !string.IsNullOrEmpty(_perFixtureUrl) ? _perFixtureUrl : (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
968 client_factory_setup.push_str(&format!(
969 " var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
970 ));
971 } else {
972 client_factory_setup.push_str(&format!(" var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
973 client_factory_setup.push_str(&format!(
974 " var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
975 ));
976 }
977 }
978
979 let call_expr = format!("{}({})", effective_function_name, final_args);
981
982 let mut effective_enum_fields: std::collections::HashSet<String> = e2e_config.fields_enum.clone();
990 for k in enum_fields.keys() {
991 effective_enum_fields.insert(k.clone());
992 }
993 if let Some(o) = cs_overrides {
994 for k in o.enum_fields.keys() {
995 effective_enum_fields.insert(k.clone());
996 }
997 }
998
999 let mut assertions_body = String::new();
1001 if !expects_error && !returns_void {
1002 for assertion in &fixture.assertions {
1003 render_assertion(
1004 &mut assertions_body,
1005 assertion,
1006 result_var,
1007 class_name,
1008 exception_class,
1009 field_resolver,
1010 effective_result_is_simple,
1011 call_config.result_is_vec || cs_overrides.is_some_and(|o| o.result_is_vec),
1012 call_config.result_is_array,
1013 effective_result_is_bytes,
1014 &effective_enum_fields,
1015 );
1016 }
1017 }
1018
1019 let ctx = minijinja::context! {
1020 is_skipped => false,
1021 expects_error => expects_error,
1022 description => description,
1023 return_type => return_type,
1024 method_name => method_name,
1025 async_kw => await_kw,
1026 call_target => call_target,
1027 setup_lines => setup_lines.clone(),
1028 call_expr => call_expr,
1029 exception_class => exception_class,
1030 client_factory_setup => client_factory_setup,
1031 has_usable_assertion => !expects_error && !returns_void,
1032 result_var => result_var,
1033 assertions_body => assertions_body,
1034 };
1035
1036 let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
1037 for line in rendered.lines() {
1039 out.push_str(" ");
1040 out.push_str(line);
1041 out.push('\n');
1042 }
1043}
1044
1045#[allow(clippy::too_many_arguments)]
1053fn render_chat_stream_test_method(
1054 out: &mut String,
1055 fixture: &Fixture,
1056 class_name: &str,
1057 call_config: &crate::config::CallConfig,
1058 cs_overrides: Option<&crate::config::CallOverride>,
1059 e2e_config: &E2eConfig,
1060 enum_fields: &HashMap<String, String>,
1061 nested_types: &HashMap<String, String>,
1062 exception_class: &str,
1063) {
1064 let method_name = fixture.id.to_upper_camel_case();
1065 let description = &fixture.description;
1066 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
1067
1068 let effective_function_name = cs_overrides
1069 .and_then(|o| o.function.as_ref())
1070 .cloned()
1071 .unwrap_or_else(|| call_config.function.to_upper_camel_case());
1072 let function_name = effective_function_name.as_str();
1073 let args = call_config.args.as_slice();
1074
1075 let top_level_options_type = e2e_config
1076 .call
1077 .overrides
1078 .get("csharp")
1079 .and_then(|o| o.options_type.as_deref());
1080 let effective_options_type = cs_overrides
1081 .and_then(|o| o.options_type.as_deref())
1082 .or(top_level_options_type);
1083 let top_level_options_via = e2e_config
1084 .call
1085 .overrides
1086 .get("csharp")
1087 .and_then(|o| o.options_via.as_deref());
1088 let effective_options_via = cs_overrides
1089 .and_then(|o| o.options_via.as_deref())
1090 .or(top_level_options_via);
1091
1092 let (setup_lines, args_str) = build_args_and_setup(
1093 &fixture.input,
1094 args,
1095 class_name,
1096 effective_options_type,
1097 effective_options_via,
1098 enum_fields,
1099 nested_types,
1100 fixture,
1101 );
1102
1103 let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
1104 e2e_config
1105 .call
1106 .overrides
1107 .get("csharp")
1108 .and_then(|o| o.client_factory.as_deref())
1109 });
1110 let mut client_factory_setup = String::new();
1111 if let Some(factory) = client_factory {
1112 let factory_name = factory.to_upper_camel_case();
1113 let fixture_id = &fixture.id;
1114 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
1115 let api_key_var_opt = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
1116 let is_live_smoke = !has_mock && api_key_var_opt.is_some();
1117 if let Some(api_key_var) = api_key_var_opt.filter(|_| has_mock) {
1118 client_factory_setup.push_str(&format!(
1119 " var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
1120 ));
1121 client_factory_setup.push_str(&format!(
1122 " var baseUrl = string.IsNullOrEmpty(apiKey)\n ? (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\"\n : null;\n"
1123 ));
1124 client_factory_setup.push_str(&format!(
1125 " Console.WriteLine($\"{fixture_id}: \" + (baseUrl == null ? \"using real API ({api_key_var} is set)\" : \"using mock server ({api_key_var} not set)\"));\n"
1126 ));
1127 client_factory_setup.push_str(&format!(
1128 " var client = {class_name}.{factory_name}(string.IsNullOrEmpty(apiKey) ? \"test-key\" : apiKey, baseUrl, null, null, null);\n"
1129 ));
1130 } else if let Some(api_key_var) = api_key_var_opt.filter(|_| is_live_smoke) {
1131 client_factory_setup.push_str(&format!(
1132 " var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
1133 ));
1134 client_factory_setup.push_str(" if (string.IsNullOrEmpty(apiKey)) { return; }\n");
1135 client_factory_setup.push_str(&format!(
1136 " var client = {class_name}.{factory_name}(apiKey, null, null, null, null);\n"
1137 ));
1138 } else if fixture.has_host_root_route() {
1139 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1140 client_factory_setup.push_str(&format!(
1141 " var _perFixtureUrl = System.Environment.GetEnvironmentVariable(\"{env_key}\");\n"
1142 ));
1143 client_factory_setup.push_str(&format!(" var baseUrl = !string.IsNullOrEmpty(_perFixtureUrl) ? _perFixtureUrl : (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
1144 client_factory_setup.push_str(&format!(
1145 " var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
1146 ));
1147 } else {
1148 client_factory_setup.push_str(&format!(
1149 " var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"
1150 ));
1151 client_factory_setup.push_str(&format!(
1152 " var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
1153 ));
1154 }
1155 }
1156
1157 let call_target = if client_factory.is_some() { "client" } else { class_name };
1158 let call_expr = format!("{call_target}.{function_name}({args_str})");
1159
1160 let mut needs_finish_reason = false;
1162 let mut needs_tool_calls_json = false;
1163 let mut needs_tool_calls_0_function_name = false;
1164 let mut needs_total_tokens = false;
1165 for a in &fixture.assertions {
1166 if let Some(f) = a.field.as_deref() {
1167 match f {
1168 "finish_reason" => needs_finish_reason = true,
1169 "tool_calls" => needs_tool_calls_json = true,
1170 "tool_calls[0].function.name" => needs_tool_calls_0_function_name = true,
1171 "usage.total_tokens" => needs_total_tokens = true,
1172 _ => {}
1173 }
1174 }
1175 }
1176
1177 let mut body = String::new();
1178 let _ = writeln!(body, " [Fact]");
1179 let _ = writeln!(body, " public async Task Test_{method_name}()");
1180 let _ = writeln!(body, " {{");
1181 let _ = writeln!(body, " // {description}");
1182 if !client_factory_setup.is_empty() {
1183 body.push_str(&client_factory_setup);
1184 }
1185 for line in &setup_lines {
1186 let _ = writeln!(body, " {line}");
1187 }
1188
1189 if expects_error {
1190 let _ = writeln!(
1193 body,
1194 " await Assert.ThrowsAnyAsync<{exception_class}>(async () => {{"
1195 );
1196 let _ = writeln!(body, " await foreach (var _chunk in {call_expr}) {{ }}");
1197 body.push_str(" });\n");
1198 body.push_str(" }\n");
1199 for line in body.lines() {
1200 out.push_str(" ");
1201 out.push_str(line);
1202 out.push('\n');
1203 }
1204 return;
1205 }
1206
1207 body.push_str(" var chunks = new List<ChatCompletionChunk>();\n");
1208 body.push_str(" var streamContent = new System.Text.StringBuilder();\n");
1209 body.push_str(" var streamComplete = false;\n");
1210 if needs_finish_reason {
1211 body.push_str(" string? lastFinishReason = null;\n");
1212 }
1213 if needs_tool_calls_json {
1214 body.push_str(" string? toolCallsJson = null;\n");
1215 }
1216 if needs_tool_calls_0_function_name {
1217 body.push_str(" string? toolCalls0FunctionName = null;\n");
1218 }
1219 if needs_total_tokens {
1220 body.push_str(" long? totalTokens = null;\n");
1221 }
1222 let _ = writeln!(body, " await foreach (var chunk in {call_expr})");
1223 body.push_str(" {\n");
1224 body.push_str(" chunks.Add(chunk);\n");
1225 body.push_str(
1226 " var choice = chunk.Choices != null && chunk.Choices.Count > 0 ? chunk.Choices[0] : null;\n",
1227 );
1228 body.push_str(" if (choice != null)\n");
1229 body.push_str(" {\n");
1230 body.push_str(" var delta = choice.Delta;\n");
1231 body.push_str(" if (delta != null && !string.IsNullOrEmpty(delta.Content))\n");
1232 body.push_str(" {\n");
1233 body.push_str(" streamContent.Append(delta.Content);\n");
1234 body.push_str(" }\n");
1235 if needs_finish_reason {
1236 body.push_str(" if (choice.FinishReason != null)\n");
1244 body.push_str(" {\n");
1245 body.push_str(
1246 " lastFinishReason = JsonNamingPolicy.SnakeCaseLower.ConvertName(choice.FinishReason.ToString()!);\n",
1247 );
1248 body.push_str(" }\n");
1249 }
1250 if needs_tool_calls_json || needs_tool_calls_0_function_name {
1251 body.push_str(" var tcs = delta?.ToolCalls;\n");
1252 body.push_str(" if (tcs != null && tcs.Count > 0)\n");
1253 body.push_str(" {\n");
1254 if needs_tool_calls_json {
1255 body.push_str(
1256 " toolCallsJson ??= JsonSerializer.Serialize(tcs.Select(tc => new { function = new { name = tc.Function?.Name } }));\n",
1257 );
1258 }
1259 if needs_tool_calls_0_function_name {
1260 body.push_str(" toolCalls0FunctionName ??= tcs[0].Function?.Name;\n");
1261 }
1262 body.push_str(" }\n");
1263 }
1264 body.push_str(" }\n");
1265 if needs_total_tokens {
1266 body.push_str(" if (chunk.Usage != null)\n");
1267 body.push_str(" {\n");
1268 body.push_str(" totalTokens = chunk.Usage.TotalTokens;\n");
1269 body.push_str(" }\n");
1270 }
1271 body.push_str(" }\n");
1272 body.push_str(" streamComplete = true;\n");
1273
1274 let mut had_explicit_complete = false;
1276 for assertion in &fixture.assertions {
1277 if assertion.field.as_deref() == Some("stream_complete") {
1278 had_explicit_complete = true;
1279 }
1280 emit_chat_stream_assertion(&mut body, assertion);
1281 }
1282 if !had_explicit_complete {
1283 body.push_str(" Assert.True(streamComplete);\n");
1284 }
1285
1286 body.push_str(" }\n");
1287
1288 for line in body.lines() {
1289 out.push_str(" ");
1290 out.push_str(line);
1291 out.push('\n');
1292 }
1293}
1294
1295fn emit_chat_stream_assertion(out: &mut String, assertion: &Assertion) {
1299 let atype = assertion.assertion_type.as_str();
1300 if atype == "not_error" || atype == "error" {
1301 return;
1302 }
1303 let field = assertion.field.as_deref().unwrap_or("");
1304
1305 enum Kind {
1306 Chunks,
1307 Bool,
1308 Str,
1309 IntTokens,
1310 Json,
1311 Unsupported,
1312 }
1313
1314 let (expr, kind) = match field {
1315 "chunks" => ("chunks", Kind::Chunks),
1316 "stream_content" => ("streamContent.ToString()", Kind::Str),
1317 "stream_complete" => ("streamComplete", Kind::Bool),
1318 "no_chunks_after_done" => ("streamComplete", Kind::Bool),
1319 "finish_reason" => ("lastFinishReason", Kind::Str),
1320 "tool_calls" => ("toolCallsJson", Kind::Json),
1321 "tool_calls[0].function.name" => ("toolCalls0FunctionName", Kind::Str),
1322 "usage.total_tokens" => ("totalTokens", Kind::IntTokens),
1323 _ => ("", Kind::Unsupported),
1324 };
1325
1326 if matches!(kind, Kind::Unsupported) {
1327 let _ = writeln!(
1328 out,
1329 " // skipped: streaming assertion on unsupported field '{field}'"
1330 );
1331 return;
1332 }
1333
1334 match (atype, &kind) {
1335 ("count_min", Kind::Chunks) => {
1336 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1337 let _ = writeln!(
1338 out,
1339 " Assert.True(chunks.Count >= {n}, \"expected at least {n} chunks\");"
1340 );
1341 }
1342 }
1343 ("count_equals", Kind::Chunks) => {
1344 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1345 let _ = writeln!(out, " Assert.Equal({n}, chunks.Count);");
1346 }
1347 }
1348 ("equals", Kind::Str) => {
1349 if let Some(val) = &assertion.value {
1350 let cs_val = json_to_csharp(val);
1351 let _ = writeln!(out, " Assert.Equal({cs_val}, {expr});");
1352 }
1353 }
1354 ("contains", Kind::Str) => {
1355 if let Some(val) = &assertion.value {
1356 let cs_val = json_to_csharp(val);
1357 let _ = writeln!(out, " Assert.Contains({cs_val}, {expr} ?? string.Empty);");
1358 }
1359 }
1360 ("not_empty", Kind::Str) => {
1361 let _ = writeln!(out, " Assert.False(string.IsNullOrEmpty({expr}));");
1362 }
1363 ("not_empty", Kind::Json) => {
1364 let _ = writeln!(out, " Assert.NotNull({expr});");
1365 }
1366 ("is_empty", Kind::Str) => {
1367 let _ = writeln!(out, " Assert.True(string.IsNullOrEmpty({expr}));");
1368 }
1369 ("is_true", Kind::Bool) => {
1370 let _ = writeln!(out, " Assert.True({expr});");
1371 }
1372 ("is_false", Kind::Bool) => {
1373 let _ = writeln!(out, " Assert.False({expr});");
1374 }
1375 ("greater_than_or_equal", Kind::IntTokens) => {
1376 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1377 let _ = writeln!(out, " Assert.True({expr} >= {n}, \"expected >= {n}\");");
1378 }
1379 }
1380 ("equals", Kind::IntTokens) => {
1381 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1382 let _ = writeln!(out, " Assert.Equal((long?){n}, {expr});");
1383 }
1384 }
1385 _ => {
1386 let _ = writeln!(
1387 out,
1388 " // skipped: streaming assertion '{atype}' on field '{field}' not supported"
1389 );
1390 }
1391 }
1392}
1393
1394#[allow(clippy::too_many_arguments)]
1398fn build_args_and_setup(
1399 input: &serde_json::Value,
1400 args: &[crate::config::ArgMapping],
1401 class_name: &str,
1402 options_type: Option<&str>,
1403 options_via: Option<&str>,
1404 enum_fields: &HashMap<String, String>,
1405 nested_types: &HashMap<String, String>,
1406 fixture: &crate::fixture::Fixture,
1407) -> (Vec<String>, String) {
1408 let fixture_id = &fixture.id;
1409 if args.is_empty() {
1410 return (Vec::new(), String::new());
1411 }
1412
1413 let mut setup_lines: Vec<String> = Vec::new();
1414 let mut parts: Vec<String> = Vec::new();
1415
1416 for arg in args {
1417 if arg.arg_type == "bytes" {
1418 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1420 let val = input.get(field);
1421 match val {
1422 None | Some(serde_json::Value::Null) if arg.optional => {
1423 parts.push("null".to_string());
1424 }
1425 None | Some(serde_json::Value::Null) => {
1426 parts.push("System.Array.Empty<byte>()".to_string());
1427 }
1428 Some(v) => {
1429 if let Some(s) = v.as_str() {
1434 let bytes_code = classify_bytes_value_csharp(s);
1435 parts.push(bytes_code);
1436 } else {
1437 let cs_str = json_to_csharp(v);
1439 parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
1440 }
1441 }
1442 }
1443 continue;
1444 }
1445
1446 if arg.arg_type == "mock_url" {
1447 if fixture.has_host_root_route() {
1448 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1449 setup_lines.push(format!(
1450 "var _pfUrl_{name} = Environment.GetEnvironmentVariable(\"{env_key}\");",
1451 name = arg.name,
1452 ));
1453 setup_lines.push(format!(
1454 "var {} = !string.IsNullOrEmpty(_pfUrl_{name}) ? _pfUrl_{name} : Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
1455 arg.name,
1456 name = arg.name,
1457 ));
1458 } else {
1459 setup_lines.push(format!(
1460 "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
1461 arg.name,
1462 ));
1463 }
1464 parts.push(arg.name.clone());
1465 continue;
1466 }
1467
1468 if arg.arg_type == "handle" {
1469 let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
1471 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1472 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1473 if config_value.is_null()
1474 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1475 {
1476 setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
1477 } else {
1478 let sorted = sort_discriminator_first(config_value.clone());
1482 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1483 let name = &arg.name;
1484 setup_lines.push(format!(
1485 "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
1486 escape_csharp(&json_str),
1487 ));
1488 setup_lines.push(format!(
1489 "var {} = {class_name}.{constructor_name}({name}Config);",
1490 arg.name,
1491 name = name,
1492 ));
1493 }
1494 parts.push(arg.name.clone());
1495 continue;
1496 }
1497
1498 let val: Option<&serde_json::Value> = if arg.field == "input" {
1501 Some(input)
1502 } else {
1503 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1504 input.get(field)
1505 };
1506 match val {
1507 None | Some(serde_json::Value::Null) if arg.optional => {
1508 parts.push("null".to_string());
1511 continue;
1512 }
1513 None | Some(serde_json::Value::Null) => {
1514 let default_val = match arg.arg_type.as_str() {
1518 "string" => "\"\"".to_string(),
1519 "int" | "integer" => "0".to_string(),
1520 "float" | "number" => "0.0d".to_string(),
1521 "bool" | "boolean" => "false".to_string(),
1522 "json_object" => {
1523 if let Some(opts_type) = options_type {
1524 format!("new {opts_type}()")
1525 } else {
1526 "null".to_string()
1527 }
1528 }
1529 _ => "null".to_string(),
1530 };
1531 parts.push(default_val);
1532 }
1533 Some(v) => {
1534 if arg.arg_type == "json_object" {
1535 if options_via == Some("from_json")
1541 && let Some(opts_type) = options_type
1542 {
1543 let sorted = sort_discriminator_first(v.clone());
1544 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1545 let escaped = escape_csharp(&json_str);
1546 parts.push(format!("{opts_type}.FromJson(\"{escaped}\")",));
1552 continue;
1553 }
1554 if let Some(arr) = v.as_array() {
1556 parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
1557 continue;
1558 }
1559 if let Some(opts_type) = options_type {
1561 if let Some(obj) = v.as_object() {
1562 parts.push(csharp_object_initializer(obj, opts_type, enum_fields, nested_types));
1563 continue;
1564 }
1565 }
1566 }
1567 parts.push(json_to_csharp(v));
1568 }
1569 }
1570 }
1571
1572 (setup_lines, parts.join(", "))
1573}
1574
1575fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
1583 match element_type {
1584 Some("BatchBytesItem") => {
1585 let items: Vec<String> = arr
1586 .iter()
1587 .filter_map(|v| v.as_object())
1588 .map(|obj| {
1589 let content = obj.get("content").and_then(|v| v.as_array());
1590 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1591 let content_code = if let Some(arr) = content {
1592 let bytes: Vec<String> = arr
1593 .iter()
1594 .filter_map(|v| v.as_u64().map(|n| format!("(byte){}", n)))
1595 .collect();
1596 format!("new byte[] {{ {} }}", bytes.join(", "))
1597 } else {
1598 "new byte[] { }".to_string()
1599 };
1600 format!(
1601 "new BatchBytesItem {{ Content = {}, MimeType = \"{}\" }}",
1602 content_code, mime_type
1603 )
1604 })
1605 .collect();
1606 format!("new List<BatchBytesItem>() {{ {} }}", items.join(", "))
1607 }
1608 Some("BatchFileItem") => {
1609 let items: Vec<String> = arr
1610 .iter()
1611 .filter_map(|v| v.as_object())
1612 .map(|obj| {
1613 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1614 format!("new BatchFileItem {{ Path = \"{}\" }}", path)
1615 })
1616 .collect();
1617 format!("new List<BatchFileItem>() {{ {} }}", items.join(", "))
1618 }
1619 Some("f32") => {
1620 let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
1621 format!("new List<float>() {{ {} }}", items.join(", "))
1622 }
1623 Some("(String, String)") => {
1624 let items: Vec<String> = arr
1625 .iter()
1626 .map(|v| {
1627 let strs: Vec<String> = v
1628 .as_array()
1629 .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
1630 format!("new List<string>() {{ {} }}", strs.join(", "))
1631 })
1632 .collect();
1633 format!("new List<List<string>>() {{ {} }}", items.join(", "))
1634 }
1635 Some(et)
1636 if et != "f32"
1637 && et != "(String, String)"
1638 && et != "string"
1639 && et != "BatchBytesItem"
1640 && et != "BatchFileItem" =>
1641 {
1642 let items: Vec<String> = arr
1644 .iter()
1645 .map(|v| {
1646 let json_str = serde_json::to_string(v).unwrap_or_default();
1647 let escaped = escape_csharp(&json_str);
1648 format!("JsonSerializer.Deserialize<{et}>(\"{escaped}\", ConfigOptions)!")
1649 })
1650 .collect();
1651 format!("new List<{et}>() {{ {} }}", items.join(", "))
1652 }
1653 _ => {
1654 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1655 format!("new List<string>() {{ {} }}", items.join(", "))
1656 }
1657 }
1658}
1659
1660fn parse_discriminated_union_access(field: &str) -> Option<(String, String, String)> {
1664 let parts: Vec<&str> = field.split('.').collect();
1665 if parts.len() >= 3 && parts.len() <= 4 {
1666 if parts[0] == "metadata" && parts[1] == "format" {
1668 let variant_name = parts[2];
1669 let known_variants = [
1671 "pdf",
1672 "docx",
1673 "excel",
1674 "email",
1675 "pptx",
1676 "archive",
1677 "image",
1678 "xml",
1679 "text",
1680 "html",
1681 "ocr",
1682 "csv",
1683 "bibtex",
1684 "citation",
1685 "fiction_book",
1686 "dbf",
1687 "jats",
1688 "epub",
1689 "pst",
1690 "code",
1691 ];
1692 if known_variants.contains(&variant_name) {
1693 let variant_pascal = variant_name.to_upper_camel_case();
1694 if parts.len() == 4 {
1695 let inner_field = parts[3];
1696 return Some((
1697 format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1698 variant_pascal,
1699 inner_field.to_string(),
1700 ));
1701 } else if parts.len() == 3 {
1702 return Some((
1704 format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1705 variant_pascal,
1706 String::new(),
1707 ));
1708 }
1709 }
1710 }
1711 }
1712 None
1713}
1714
1715fn render_discriminated_union_assertion(
1719 out: &mut String,
1720 assertion: &Assertion,
1721 variant_var: &str,
1722 inner_field: &str,
1723 _result_is_vec: bool,
1724) {
1725 if inner_field.is_empty() {
1726 return; }
1728
1729 let field_pascal = inner_field.to_upper_camel_case();
1730 let field_expr = format!("{variant_var}.Value.{field_pascal}");
1731
1732 match assertion.assertion_type.as_str() {
1733 "equals" => {
1734 if let Some(expected) = &assertion.value {
1735 let cs_val = json_to_csharp(expected);
1736 if expected.is_string() {
1737 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr}!.Trim());");
1738 } else if expected.as_bool() == Some(true) {
1739 let _ = writeln!(out, " Assert.True({field_expr});");
1740 } else if expected.as_bool() == Some(false) {
1741 let _ = writeln!(out, " Assert.False({field_expr});");
1742 } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
1743 let _ = writeln!(out, " Assert.True({field_expr} == {cs_val});");
1744 } else {
1745 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr});");
1746 }
1747 }
1748 }
1749 "greater_than_or_equal" => {
1750 if let Some(val) = &assertion.value {
1751 let cs_val = json_to_csharp(val);
1752 let _ = writeln!(
1753 out,
1754 " Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1755 );
1756 }
1757 }
1758 "contains_all" => {
1759 if let Some(values) = &assertion.values {
1760 let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1761 for val in values {
1762 let lower_val = val.as_str().map(|s| s.to_lowercase());
1763 let cs_val = lower_val
1764 .as_deref()
1765 .map(|s| format!("\"{}\"", escape_csharp(s)))
1766 .unwrap_or_else(|| json_to_csharp(val));
1767 let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1768 }
1769 }
1770 }
1771 "contains" => {
1772 if let Some(expected) = &assertion.value {
1773 let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1774 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
1775 let cs_val = lower_expected
1776 .as_deref()
1777 .map(|s| format!("\"{}\"", escape_csharp(s)))
1778 .unwrap_or_else(|| json_to_csharp(expected));
1779 let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1780 }
1781 }
1782 "not_empty" => {
1783 let _ = writeln!(out, " Assert.NotEmpty({field_expr});");
1784 }
1785 "is_empty" => {
1786 let _ = writeln!(out, " Assert.Empty({field_expr});");
1787 }
1788 _ => {
1789 let _ = writeln!(
1790 out,
1791 " // skipped: assertion type '{}' not yet supported for discriminated union fields",
1792 assertion.assertion_type
1793 );
1794 }
1795 }
1796}
1797
1798#[allow(clippy::too_many_arguments)]
1799fn render_assertion(
1800 out: &mut String,
1801 assertion: &Assertion,
1802 result_var: &str,
1803 class_name: &str,
1804 exception_class: &str,
1805 field_resolver: &FieldResolver,
1806 result_is_simple: bool,
1807 result_is_vec: bool,
1808 result_is_array: bool,
1809 result_is_bytes: bool,
1810 fields_enum: &std::collections::HashSet<String>,
1811) {
1812 if result_is_bytes {
1816 match assertion.assertion_type.as_str() {
1817 "not_empty" => {
1818 let _ = writeln!(out, " Assert.NotEmpty({result_var});");
1819 return;
1820 }
1821 "is_empty" => {
1822 let _ = writeln!(out, " Assert.Empty({result_var});");
1823 return;
1824 }
1825 "count_equals" | "length_equals" => {
1826 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1827 let _ = writeln!(out, " Assert.Equal({n}, {result_var}.Length);");
1828 }
1829 return;
1830 }
1831 "count_min" | "length_min" => {
1832 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1833 let _ = writeln!(out, " Assert.True({result_var}.Length >= {n});");
1834 }
1835 return;
1836 }
1837 "not_error" => {
1838 let _ = writeln!(out, " Assert.NotNull({result_var});");
1839 return;
1840 }
1841 _ => {
1842 let _ = writeln!(
1846 out,
1847 " // skipped: assertion type '{}' not supported on byte[] result",
1848 assertion.assertion_type
1849 );
1850 return;
1851 }
1852 }
1853 }
1854 if let Some(f) = &assertion.field {
1857 match f.as_str() {
1858 "chunks_have_content" => {
1859 let synthetic_pred =
1860 format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
1861 let synthetic_pred_type = match assertion.assertion_type.as_str() {
1862 "is_true" => "is_true",
1863 "is_false" => "is_false",
1864 _ => {
1865 out.push_str(&format!(
1866 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
1867 ));
1868 return;
1869 }
1870 };
1871 let rendered = crate::template_env::render(
1872 "csharp/assertion.jinja",
1873 minijinja::context! {
1874 assertion_type => "synthetic_assertion",
1875 synthetic_pred => synthetic_pred,
1876 synthetic_pred_type => synthetic_pred_type,
1877 },
1878 );
1879 out.push_str(&rendered);
1880 return;
1881 }
1882 "chunks_have_embeddings" => {
1883 let synthetic_pred =
1884 format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
1885 let synthetic_pred_type = match assertion.assertion_type.as_str() {
1886 "is_true" => "is_true",
1887 "is_false" => "is_false",
1888 _ => {
1889 out.push_str(&format!(
1890 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
1891 ));
1892 return;
1893 }
1894 };
1895 let rendered = crate::template_env::render(
1896 "csharp/assertion.jinja",
1897 minijinja::context! {
1898 assertion_type => "synthetic_assertion",
1899 synthetic_pred => synthetic_pred,
1900 synthetic_pred_type => synthetic_pred_type,
1901 },
1902 );
1903 out.push_str(&rendered);
1904 return;
1905 }
1906 "embeddings" => {
1910 match assertion.assertion_type.as_str() {
1911 "count_equals" => {
1912 if let Some(val) = &assertion.value {
1913 if let Some(n) = val.as_u64() {
1914 let rendered = crate::template_env::render(
1915 "csharp/assertion.jinja",
1916 minijinja::context! {
1917 assertion_type => "synthetic_embeddings_count_equals",
1918 synthetic_pred => format!("{result_var}.Count"),
1919 n => n,
1920 },
1921 );
1922 out.push_str(&rendered);
1923 }
1924 }
1925 }
1926 "count_min" => {
1927 if let Some(val) = &assertion.value {
1928 if let Some(n) = val.as_u64() {
1929 let rendered = crate::template_env::render(
1930 "csharp/assertion.jinja",
1931 minijinja::context! {
1932 assertion_type => "synthetic_embeddings_count_min",
1933 synthetic_pred => format!("{result_var}.Count"),
1934 n => n,
1935 },
1936 );
1937 out.push_str(&rendered);
1938 }
1939 }
1940 }
1941 "not_empty" => {
1942 let rendered = crate::template_env::render(
1943 "csharp/assertion.jinja",
1944 minijinja::context! {
1945 assertion_type => "synthetic_embeddings_not_empty",
1946 synthetic_pred => result_var.to_string(),
1947 },
1948 );
1949 out.push_str(&rendered);
1950 }
1951 "is_empty" => {
1952 let rendered = crate::template_env::render(
1953 "csharp/assertion.jinja",
1954 minijinja::context! {
1955 assertion_type => "synthetic_embeddings_is_empty",
1956 synthetic_pred => result_var.to_string(),
1957 },
1958 );
1959 out.push_str(&rendered);
1960 }
1961 _ => {
1962 out.push_str(
1963 " // skipped: unsupported assertion type on synthetic field 'embeddings'\n",
1964 );
1965 }
1966 }
1967 return;
1968 }
1969 "embedding_dimensions" => {
1970 let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
1971 match assertion.assertion_type.as_str() {
1972 "equals" => {
1973 if let Some(val) = &assertion.value {
1974 if let Some(n) = val.as_u64() {
1975 let rendered = crate::template_env::render(
1976 "csharp/assertion.jinja",
1977 minijinja::context! {
1978 assertion_type => "synthetic_embedding_dimensions_equals",
1979 synthetic_pred => expr,
1980 n => n,
1981 },
1982 );
1983 out.push_str(&rendered);
1984 }
1985 }
1986 }
1987 "greater_than" => {
1988 if let Some(val) = &assertion.value {
1989 if let Some(n) = val.as_u64() {
1990 let rendered = crate::template_env::render(
1991 "csharp/assertion.jinja",
1992 minijinja::context! {
1993 assertion_type => "synthetic_embedding_dimensions_greater_than",
1994 synthetic_pred => expr,
1995 n => n,
1996 },
1997 );
1998 out.push_str(&rendered);
1999 }
2000 }
2001 }
2002 _ => {
2003 out.push_str(" // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n");
2004 }
2005 }
2006 return;
2007 }
2008 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
2009 let synthetic_pred = match f.as_str() {
2010 "embeddings_valid" => {
2011 format!("{result_var}.All(e => e.Count > 0)")
2012 }
2013 "embeddings_finite" => {
2014 format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
2015 }
2016 "embeddings_non_zero" => {
2017 format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
2018 }
2019 "embeddings_normalized" => {
2020 format!(
2021 "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
2022 )
2023 }
2024 _ => unreachable!(),
2025 };
2026 let synthetic_pred_type = match assertion.assertion_type.as_str() {
2027 "is_true" => "is_true",
2028 "is_false" => "is_false",
2029 _ => {
2030 out.push_str(&format!(
2031 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
2032 ));
2033 return;
2034 }
2035 };
2036 let rendered = crate::template_env::render(
2037 "csharp/assertion.jinja",
2038 minijinja::context! {
2039 assertion_type => "synthetic_assertion",
2040 synthetic_pred => synthetic_pred,
2041 synthetic_pred_type => synthetic_pred_type,
2042 },
2043 );
2044 out.push_str(&rendered);
2045 return;
2046 }
2047 "keywords" | "keywords_count" => {
2050 let skipped_reason = format!("field '{f}' not available on C# ExtractionResult");
2051 let rendered = crate::template_env::render(
2052 "csharp/assertion.jinja",
2053 minijinja::context! {
2054 skipped_reason => skipped_reason,
2055 },
2056 );
2057 out.push_str(&rendered);
2058 return;
2059 }
2060 _ => {}
2061 }
2062 }
2063
2064 if let Some(f) = &assertion.field {
2066 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
2067 let skipped_reason = format!("field '{f}' not available on result type");
2068 let rendered = crate::template_env::render(
2069 "csharp/assertion.jinja",
2070 minijinja::context! {
2071 skipped_reason => skipped_reason,
2072 },
2073 );
2074 out.push_str(&rendered);
2075 return;
2076 }
2077 }
2078
2079 let is_count_assertion = matches!(
2082 assertion.assertion_type.as_str(),
2083 "count_equals" | "count_min" | "count_max"
2084 );
2085 let is_no_field = assertion.field.is_none() || assertion.field.as_ref().is_some_and(|f| f.is_empty());
2086 let use_list_directly = result_is_vec && is_count_assertion && is_no_field;
2087
2088 let effective_result_var: String = if result_is_vec && !use_list_directly {
2089 format!("{result_var}[0]")
2090 } else {
2091 result_var.to_string()
2092 };
2093
2094 let is_discriminated_union = assertion
2096 .field
2097 .as_ref()
2098 .is_some_and(|f| parse_discriminated_union_access(f).is_some());
2099
2100 if is_discriminated_union {
2102 if let Some((_, variant_name, inner_field)) = assertion
2103 .field
2104 .as_ref()
2105 .and_then(|f| parse_discriminated_union_access(f))
2106 {
2107 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2109 inner_field.hash(&mut hasher);
2110 let var_hash = format!("{:x}", hasher.finish());
2111 let variant_var = format!("variant_{}", &var_hash[..8]);
2112 let _ = writeln!(
2113 out,
2114 " if ({effective_result_var}.Metadata.Format is FormatMetadata.{} {})",
2115 variant_name, &variant_var
2116 );
2117 let _ = writeln!(out, " {{");
2118 render_discriminated_union_assertion(out, assertion, &variant_var, &inner_field, result_is_vec);
2119 let _ = writeln!(out, " }}");
2120 let _ = writeln!(out, " else");
2121 let _ = writeln!(out, " {{");
2122 let _ = writeln!(
2123 out,
2124 " Assert.Fail(\"Expected {} format metadata\");",
2125 variant_name.to_lowercase()
2126 );
2127 let _ = writeln!(out, " }}");
2128 return;
2129 }
2130 }
2131
2132 let field_expr = if result_is_simple {
2133 effective_result_var.clone()
2134 } else {
2135 match &assertion.field {
2136 Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
2137 _ => effective_result_var.clone(),
2138 }
2139 };
2140
2141 let field_needs_json_serialize = if result_is_simple {
2145 result_is_array
2148 } else {
2149 match &assertion.field {
2150 Some(f) if !f.is_empty() => field_resolver.is_array(f),
2151 _ => !result_is_simple,
2153 }
2154 };
2155 let field_as_str = if field_needs_json_serialize {
2157 format!("JsonSerializer.Serialize({field_expr})")
2158 } else {
2159 format!("{field_expr}.ToString()")
2160 };
2161
2162 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
2166 let resolved = field_resolver.resolve(f);
2167 fields_enum.contains(f) || fields_enum.contains(resolved)
2168 });
2169
2170 match assertion.assertion_type.as_str() {
2171 "equals" => {
2172 if let Some(expected) = &assertion.value {
2173 if field_is_enum && expected.is_string() {
2180 let s_lower = expected.as_str().map(|s| s.to_lowercase()).unwrap_or_default();
2181 let _ = writeln!(
2182 out,
2183 " Assert.Equal(\"{}\", {field_expr} == null ? null : JsonNamingPolicy.SnakeCaseLower.ConvertName({field_expr}.ToString()!));",
2184 escape_csharp(&s_lower)
2185 );
2186 return;
2187 }
2188 let cs_val = json_to_csharp(expected);
2189 let is_string_val = expected.is_string();
2190 let is_bool_true = expected.as_bool() == Some(true);
2191 let is_bool_false = expected.as_bool() == Some(false);
2192 let is_integer_val = expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0);
2193
2194 let rendered = crate::template_env::render(
2195 "csharp/assertion.jinja",
2196 minijinja::context! {
2197 assertion_type => "equals",
2198 field_expr => field_expr.clone(),
2199 cs_val => cs_val,
2200 is_string_val => is_string_val,
2201 is_bool_true => is_bool_true,
2202 is_bool_false => is_bool_false,
2203 is_integer_val => is_integer_val,
2204 },
2205 );
2206 out.push_str(&rendered);
2207 }
2208 }
2209 "contains" => {
2210 if let Some(expected) = &assertion.value {
2211 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
2218 let cs_val = lower_expected
2219 .as_deref()
2220 .map(|s| format!("\"{}\"", escape_csharp(s)))
2221 .unwrap_or_else(|| json_to_csharp(expected));
2222
2223 let rendered = crate::template_env::render(
2224 "csharp/assertion.jinja",
2225 minijinja::context! {
2226 assertion_type => "contains",
2227 field_as_str => field_as_str.clone(),
2228 cs_val => cs_val,
2229 },
2230 );
2231 out.push_str(&rendered);
2232 }
2233 }
2234 "contains_all" => {
2235 if let Some(values) = &assertion.values {
2236 let values_cs_lower: Vec<String> = values
2237 .iter()
2238 .map(|val| {
2239 let lower_val = val.as_str().map(|s| s.to_lowercase());
2240 lower_val
2241 .as_deref()
2242 .map(|s| format!("\"{}\"", escape_csharp(s)))
2243 .unwrap_or_else(|| json_to_csharp(val))
2244 })
2245 .collect();
2246
2247 let rendered = crate::template_env::render(
2248 "csharp/assertion.jinja",
2249 minijinja::context! {
2250 assertion_type => "contains_all",
2251 field_as_str => field_as_str.clone(),
2252 values_cs_lower => values_cs_lower,
2253 },
2254 );
2255 out.push_str(&rendered);
2256 }
2257 }
2258 "not_contains" => {
2259 if let Some(expected) = &assertion.value {
2260 let cs_val = json_to_csharp(expected);
2261
2262 let rendered = crate::template_env::render(
2263 "csharp/assertion.jinja",
2264 minijinja::context! {
2265 assertion_type => "not_contains",
2266 field_as_str => field_as_str.clone(),
2267 cs_val => cs_val,
2268 },
2269 );
2270 out.push_str(&rendered);
2271 }
2272 }
2273 "not_empty" => {
2274 let rendered = crate::template_env::render(
2275 "csharp/assertion.jinja",
2276 minijinja::context! {
2277 assertion_type => "not_empty",
2278 field_expr => field_expr.clone(),
2279 field_needs_json_serialize => field_needs_json_serialize,
2280 },
2281 );
2282 out.push_str(&rendered);
2283 }
2284 "is_empty" => {
2285 let rendered = crate::template_env::render(
2286 "csharp/assertion.jinja",
2287 minijinja::context! {
2288 assertion_type => "is_empty",
2289 field_expr => field_expr.clone(),
2290 field_needs_json_serialize => field_needs_json_serialize,
2291 },
2292 );
2293 out.push_str(&rendered);
2294 }
2295 "contains_any" => {
2296 if let Some(values) = &assertion.values {
2297 let checks: Vec<String> = values
2298 .iter()
2299 .map(|v| {
2300 let cs_val = json_to_csharp(v);
2301 format!("{field_as_str}.Contains({cs_val})")
2302 })
2303 .collect();
2304 let contains_any_expr = checks.join(" || ");
2305
2306 let rendered = crate::template_env::render(
2307 "csharp/assertion.jinja",
2308 minijinja::context! {
2309 assertion_type => "contains_any",
2310 contains_any_expr => contains_any_expr,
2311 },
2312 );
2313 out.push_str(&rendered);
2314 }
2315 }
2316 "greater_than" => {
2317 if let Some(val) = &assertion.value {
2318 let cs_val = json_to_csharp(val);
2319
2320 let rendered = crate::template_env::render(
2321 "csharp/assertion.jinja",
2322 minijinja::context! {
2323 assertion_type => "greater_than",
2324 field_expr => field_expr.clone(),
2325 cs_val => cs_val,
2326 },
2327 );
2328 out.push_str(&rendered);
2329 }
2330 }
2331 "less_than" => {
2332 if let Some(val) = &assertion.value {
2333 let cs_val = json_to_csharp(val);
2334
2335 let rendered = crate::template_env::render(
2336 "csharp/assertion.jinja",
2337 minijinja::context! {
2338 assertion_type => "less_than",
2339 field_expr => field_expr.clone(),
2340 cs_val => cs_val,
2341 },
2342 );
2343 out.push_str(&rendered);
2344 }
2345 }
2346 "greater_than_or_equal" => {
2347 if let Some(val) = &assertion.value {
2348 let cs_val = json_to_csharp(val);
2349
2350 let rendered = crate::template_env::render(
2351 "csharp/assertion.jinja",
2352 minijinja::context! {
2353 assertion_type => "greater_than_or_equal",
2354 field_expr => field_expr.clone(),
2355 cs_val => cs_val,
2356 },
2357 );
2358 out.push_str(&rendered);
2359 }
2360 }
2361 "less_than_or_equal" => {
2362 if let Some(val) = &assertion.value {
2363 let cs_val = json_to_csharp(val);
2364
2365 let rendered = crate::template_env::render(
2366 "csharp/assertion.jinja",
2367 minijinja::context! {
2368 assertion_type => "less_than_or_equal",
2369 field_expr => field_expr.clone(),
2370 cs_val => cs_val,
2371 },
2372 );
2373 out.push_str(&rendered);
2374 }
2375 }
2376 "starts_with" => {
2377 if let Some(expected) = &assertion.value {
2378 let cs_val = json_to_csharp(expected);
2379
2380 let rendered = crate::template_env::render(
2381 "csharp/assertion.jinja",
2382 minijinja::context! {
2383 assertion_type => "starts_with",
2384 field_expr => field_expr.clone(),
2385 cs_val => cs_val,
2386 },
2387 );
2388 out.push_str(&rendered);
2389 }
2390 }
2391 "ends_with" => {
2392 if let Some(expected) = &assertion.value {
2393 let cs_val = json_to_csharp(expected);
2394
2395 let rendered = crate::template_env::render(
2396 "csharp/assertion.jinja",
2397 minijinja::context! {
2398 assertion_type => "ends_with",
2399 field_expr => field_expr.clone(),
2400 cs_val => cs_val,
2401 },
2402 );
2403 out.push_str(&rendered);
2404 }
2405 }
2406 "min_length" => {
2407 if let Some(val) = &assertion.value {
2408 if let Some(n) = val.as_u64() {
2409 let rendered = crate::template_env::render(
2410 "csharp/assertion.jinja",
2411 minijinja::context! {
2412 assertion_type => "min_length",
2413 field_expr => field_expr.clone(),
2414 n => n,
2415 },
2416 );
2417 out.push_str(&rendered);
2418 }
2419 }
2420 }
2421 "max_length" => {
2422 if let Some(val) = &assertion.value {
2423 if let Some(n) = val.as_u64() {
2424 let rendered = crate::template_env::render(
2425 "csharp/assertion.jinja",
2426 minijinja::context! {
2427 assertion_type => "max_length",
2428 field_expr => field_expr.clone(),
2429 n => n,
2430 },
2431 );
2432 out.push_str(&rendered);
2433 }
2434 }
2435 }
2436 "count_min" => {
2437 if let Some(val) = &assertion.value {
2438 if let Some(n) = val.as_u64() {
2439 let rendered = crate::template_env::render(
2440 "csharp/assertion.jinja",
2441 minijinja::context! {
2442 assertion_type => "count_min",
2443 field_expr => field_expr.clone(),
2444 n => n,
2445 },
2446 );
2447 out.push_str(&rendered);
2448 }
2449 }
2450 }
2451 "count_equals" => {
2452 if let Some(val) = &assertion.value {
2453 if let Some(n) = val.as_u64() {
2454 let rendered = crate::template_env::render(
2455 "csharp/assertion.jinja",
2456 minijinja::context! {
2457 assertion_type => "count_equals",
2458 field_expr => field_expr.clone(),
2459 n => n,
2460 },
2461 );
2462 out.push_str(&rendered);
2463 }
2464 }
2465 }
2466 "is_true" => {
2467 let rendered = crate::template_env::render(
2468 "csharp/assertion.jinja",
2469 minijinja::context! {
2470 assertion_type => "is_true",
2471 field_expr => field_expr.clone(),
2472 },
2473 );
2474 out.push_str(&rendered);
2475 }
2476 "is_false" => {
2477 let rendered = crate::template_env::render(
2478 "csharp/assertion.jinja",
2479 minijinja::context! {
2480 assertion_type => "is_false",
2481 field_expr => field_expr.clone(),
2482 },
2483 );
2484 out.push_str(&rendered);
2485 }
2486 "not_error" => {
2487 let rendered = crate::template_env::render(
2489 "csharp/assertion.jinja",
2490 minijinja::context! {
2491 assertion_type => "not_error",
2492 },
2493 );
2494 out.push_str(&rendered);
2495 }
2496 "error" => {
2497 let rendered = crate::template_env::render(
2499 "csharp/assertion.jinja",
2500 minijinja::context! {
2501 assertion_type => "error",
2502 },
2503 );
2504 out.push_str(&rendered);
2505 }
2506 "method_result" => {
2507 if let Some(method_name) = &assertion.method {
2508 let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
2509 let check = assertion.check.as_deref().unwrap_or("is_true");
2510
2511 match check {
2512 "equals" => {
2513 if let Some(val) = &assertion.value {
2514 let is_check_bool_true = val.as_bool() == Some(true);
2515 let is_check_bool_false = val.as_bool() == Some(false);
2516 let cs_check_val = json_to_csharp(val);
2517
2518 let rendered = crate::template_env::render(
2519 "csharp/assertion.jinja",
2520 minijinja::context! {
2521 assertion_type => "method_result",
2522 check => "equals",
2523 call_expr => call_expr.clone(),
2524 is_check_bool_true => is_check_bool_true,
2525 is_check_bool_false => is_check_bool_false,
2526 cs_check_val => cs_check_val,
2527 },
2528 );
2529 out.push_str(&rendered);
2530 }
2531 }
2532 "is_true" => {
2533 let rendered = crate::template_env::render(
2534 "csharp/assertion.jinja",
2535 minijinja::context! {
2536 assertion_type => "method_result",
2537 check => "is_true",
2538 call_expr => call_expr.clone(),
2539 },
2540 );
2541 out.push_str(&rendered);
2542 }
2543 "is_false" => {
2544 let rendered = crate::template_env::render(
2545 "csharp/assertion.jinja",
2546 minijinja::context! {
2547 assertion_type => "method_result",
2548 check => "is_false",
2549 call_expr => call_expr.clone(),
2550 },
2551 );
2552 out.push_str(&rendered);
2553 }
2554 "greater_than_or_equal" => {
2555 if let Some(val) = &assertion.value {
2556 let check_n = val.as_u64().unwrap_or(0);
2557
2558 let rendered = crate::template_env::render(
2559 "csharp/assertion.jinja",
2560 minijinja::context! {
2561 assertion_type => "method_result",
2562 check => "greater_than_or_equal",
2563 call_expr => call_expr.clone(),
2564 check_n => check_n,
2565 },
2566 );
2567 out.push_str(&rendered);
2568 }
2569 }
2570 "count_min" => {
2571 if let Some(val) = &assertion.value {
2572 let check_n = val.as_u64().unwrap_or(0);
2573
2574 let rendered = crate::template_env::render(
2575 "csharp/assertion.jinja",
2576 minijinja::context! {
2577 assertion_type => "method_result",
2578 check => "count_min",
2579 call_expr => call_expr.clone(),
2580 check_n => check_n,
2581 },
2582 );
2583 out.push_str(&rendered);
2584 }
2585 }
2586 "is_error" => {
2587 let rendered = crate::template_env::render(
2588 "csharp/assertion.jinja",
2589 minijinja::context! {
2590 assertion_type => "method_result",
2591 check => "is_error",
2592 call_expr => call_expr.clone(),
2593 exception_class => exception_class,
2594 },
2595 );
2596 out.push_str(&rendered);
2597 }
2598 "contains" => {
2599 if let Some(val) = &assertion.value {
2600 let cs_check_val = json_to_csharp(val);
2601
2602 let rendered = crate::template_env::render(
2603 "csharp/assertion.jinja",
2604 minijinja::context! {
2605 assertion_type => "method_result",
2606 check => "contains",
2607 call_expr => call_expr.clone(),
2608 cs_check_val => cs_check_val,
2609 },
2610 );
2611 out.push_str(&rendered);
2612 }
2613 }
2614 other_check => {
2615 panic!("C# e2e generator: unsupported method_result check type: {other_check}");
2616 }
2617 }
2618 } else {
2619 panic!("C# e2e generator: method_result assertion missing 'method' field");
2620 }
2621 }
2622 "matches_regex" => {
2623 if let Some(expected) = &assertion.value {
2624 let cs_val = json_to_csharp(expected);
2625
2626 let rendered = crate::template_env::render(
2627 "csharp/assertion.jinja",
2628 minijinja::context! {
2629 assertion_type => "matches_regex",
2630 field_expr => field_expr.clone(),
2631 cs_val => cs_val,
2632 },
2633 );
2634 out.push_str(&rendered);
2635 }
2636 }
2637 other => {
2638 panic!("C# e2e generator: unsupported assertion type: {other}");
2639 }
2640 }
2641}
2642
2643fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
2650 match value {
2651 serde_json::Value::Object(map) => {
2652 let mut sorted = serde_json::Map::with_capacity(map.len());
2653 if let Some(type_val) = map.get("type") {
2655 sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
2656 }
2657 for (k, v) in map {
2658 if k != "type" {
2659 sorted.insert(k, sort_discriminator_first(v));
2660 }
2661 }
2662 serde_json::Value::Object(sorted)
2663 }
2664 serde_json::Value::Array(arr) => {
2665 serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
2666 }
2667 other => other,
2668 }
2669}
2670
2671fn json_to_csharp(value: &serde_json::Value) -> String {
2673 match value {
2674 serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
2675 serde_json::Value::Bool(true) => "true".to_string(),
2676 serde_json::Value::Bool(false) => "false".to_string(),
2677 serde_json::Value::Number(n) => {
2678 if n.is_f64() {
2679 format!("{}d", n)
2680 } else {
2681 n.to_string()
2682 }
2683 }
2684 serde_json::Value::Null => "null".to_string(),
2685 serde_json::Value::Array(arr) => {
2686 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
2687 format!("new[] {{ {} }}", items.join(", "))
2688 }
2689 serde_json::Value::Object(_) => {
2690 let json_str = serde_json::to_string(value).unwrap_or_default();
2691 format!("\"{}\"", escape_csharp(&json_str))
2692 }
2693 }
2694}
2695
2696fn default_csharp_nested_types() -> HashMap<String, String> {
2703 [
2704 ("chunking", "ChunkingConfig"),
2705 ("ocr", "OcrConfig"),
2706 ("images", "ImageExtractionConfig"),
2707 ("html_output", "HtmlOutputConfig"),
2708 ("language_detection", "LanguageDetectionConfig"),
2709 ("postprocessor", "PostProcessorConfig"),
2710 ("acceleration", "AccelerationConfig"),
2711 ("email", "EmailConfig"),
2712 ("pages", "PageConfig"),
2713 ("pdf_options", "PdfConfig"),
2714 ("layout", "LayoutDetectionConfig"),
2715 ("tree_sitter", "TreeSitterConfig"),
2716 ("structured_extraction", "StructuredExtractionConfig"),
2717 ("content_filter", "ContentFilterConfig"),
2718 ("token_reduction", "TokenReductionOptions"),
2719 ("security_limits", "SecurityLimits"),
2720 ("format", "FormatMetadata"),
2721 ]
2722 .iter()
2723 .map(|(k, v)| (k.to_string(), v.to_string()))
2724 .collect()
2725}
2726
2727fn csharp_object_initializer(
2735 obj: &serde_json::Map<String, serde_json::Value>,
2736 type_name: &str,
2737 enum_fields: &HashMap<String, String>,
2738 nested_types: &HashMap<String, String>,
2739) -> String {
2740 if obj.is_empty() {
2741 return format!("new {type_name}()");
2742 }
2743
2744 static IMPLICIT_ENUM_FIELDS: &[(&str, &str)] = &[("output_format", "OutputFormat")];
2747
2748 let props: Vec<String> = obj
2749 .iter()
2750 .map(|(key, val)| {
2751 let pascal_key = key.to_upper_camel_case();
2752 let implicit_enum_type = IMPLICIT_ENUM_FIELDS
2753 .iter()
2754 .find(|(k, _)| *k == key.as_str())
2755 .map(|(_, t)| *t);
2756 let cs_val =
2757 if let Some(enum_type) = enum_fields.get(key.as_str()).map(String::as_str).or(implicit_enum_type) {
2758 if val.is_null() {
2760 "null".to_string()
2761 } else {
2762 let member = val
2763 .as_str()
2764 .map(|s| s.to_upper_camel_case())
2765 .unwrap_or_else(|| "null".to_string());
2766 format!("{enum_type}.{member}")
2767 }
2768 } else if let Some(nested_type) = nested_types.get(key.as_str()) {
2769 let normalized = normalize_csharp_enum_values(val, enum_fields);
2771 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
2772 format!(
2773 "JsonSerializer.Deserialize<{nested_type}>(\"{}\", ConfigOptions)!",
2774 escape_csharp(&json_str)
2775 )
2776 } else if let Some(arr) = val.as_array() {
2777 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
2779 format!("new List<string> {{ {} }}", items.join(", "))
2780 } else {
2781 json_to_csharp(val)
2782 };
2783 format!("{pascal_key} = {cs_val}")
2784 })
2785 .collect();
2786 format!("new {} {{ {} }}", type_name, props.join(", "))
2787}
2788
2789fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
2794 match value {
2795 serde_json::Value::Object(map) => {
2796 let mut result = map.clone();
2797 for (key, val) in result.iter_mut() {
2798 if enum_fields.contains_key(key) {
2799 if let Some(s) = val.as_str() {
2801 *val = serde_json::Value::String(s.to_lowercase());
2802 }
2803 }
2804 }
2805 serde_json::Value::Object(result)
2806 }
2807 other => other.clone(),
2808 }
2809}
2810
2811fn build_csharp_visitor(
2822 setup_lines: &mut Vec<String>,
2823 class_decls: &mut Vec<String>,
2824 fixture_id: &str,
2825 visitor_spec: &crate::fixture::VisitorSpec,
2826) -> String {
2827 use heck::ToUpperCamelCase;
2828 let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
2829 let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
2830
2831 setup_lines.push(format!("var {var_name} = new {class_name}();"));
2832
2833 let mut decl = String::new();
2835 decl.push_str(&format!(" private sealed class {class_name} : IHtmlVisitor\n"));
2836 decl.push_str(" {\n");
2837
2838 let all_methods = [
2840 "visit_element_start",
2841 "visit_element_end",
2842 "visit_text",
2843 "visit_link",
2844 "visit_image",
2845 "visit_heading",
2846 "visit_code_block",
2847 "visit_code_inline",
2848 "visit_list_item",
2849 "visit_list_start",
2850 "visit_list_end",
2851 "visit_table_start",
2852 "visit_table_row",
2853 "visit_table_end",
2854 "visit_blockquote",
2855 "visit_strong",
2856 "visit_emphasis",
2857 "visit_strikethrough",
2858 "visit_underline",
2859 "visit_subscript",
2860 "visit_superscript",
2861 "visit_mark",
2862 "visit_line_break",
2863 "visit_horizontal_rule",
2864 "visit_custom_element",
2865 "visit_definition_list_start",
2866 "visit_definition_term",
2867 "visit_definition_description",
2868 "visit_definition_list_end",
2869 "visit_form",
2870 "visit_input",
2871 "visit_button",
2872 "visit_audio",
2873 "visit_video",
2874 "visit_iframe",
2875 "visit_details",
2876 "visit_summary",
2877 "visit_figure_start",
2878 "visit_figcaption",
2879 "visit_figure_end",
2880 ];
2881
2882 for method_name in &all_methods {
2884 if let Some(action) = visitor_spec.callbacks.get(*method_name) {
2885 emit_csharp_visitor_method(&mut decl, method_name, action);
2886 } else {
2887 emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
2889 }
2890 }
2891
2892 decl.push_str(" }\n");
2893 class_decls.push(decl);
2894
2895 var_name
2896}
2897
2898fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
2900 let camel_method = method_to_camel(method_name);
2901 let params = match method_name {
2902 "visit_link" => "NodeContext ctx, string href, string text, string title",
2903 "visit_image" => "NodeContext ctx, string src, string alt, string title",
2904 "visit_heading" => "NodeContext ctx, uint level, string text, string id",
2905 "visit_code_block" => "NodeContext ctx, string lang, string code",
2906 "visit_code_inline"
2907 | "visit_strong"
2908 | "visit_emphasis"
2909 | "visit_strikethrough"
2910 | "visit_underline"
2911 | "visit_subscript"
2912 | "visit_superscript"
2913 | "visit_mark"
2914 | "visit_button"
2915 | "visit_summary"
2916 | "visit_figcaption"
2917 | "visit_definition_term"
2918 | "visit_definition_description" => "NodeContext ctx, string text",
2919 "visit_text" => "NodeContext ctx, string text",
2920 "visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
2921 "visit_blockquote" => "NodeContext ctx, string content, ulong depth",
2922 "visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
2923 "visit_custom_element" => "NodeContext ctx, string tagName, string html",
2924 "visit_form" => "NodeContext ctx, string actionUrl, string method",
2925 "visit_input" => "NodeContext ctx, string inputType, string name, string value",
2926 "visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
2927 "visit_details" => "NodeContext ctx, bool isOpen",
2928 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
2929 "NodeContext ctx, string output"
2930 }
2931 "visit_list_start" => "NodeContext ctx, bool ordered",
2932 "visit_list_end" => "NodeContext ctx, bool ordered, string output",
2933 "visit_element_start"
2934 | "visit_table_start"
2935 | "visit_definition_list_start"
2936 | "visit_figure_start"
2937 | "visit_line_break"
2938 | "visit_horizontal_rule" => "NodeContext ctx",
2939 _ => "NodeContext ctx",
2940 };
2941
2942 let (action_type, action_value) = match action {
2943 CallbackAction::Skip => ("skip", String::new()),
2944 CallbackAction::Continue => ("continue", String::new()),
2945 CallbackAction::PreserveHtml => ("preserve_html", String::new()),
2946 CallbackAction::Custom { output } => ("custom", escape_csharp(output)),
2947 CallbackAction::CustomTemplate { template, .. } => {
2948 let camel = snake_case_template_to_camel(template);
2949 ("custom_template", escape_csharp(&camel))
2950 }
2951 };
2952
2953 let rendered = crate::template_env::render(
2954 "csharp/visitor_method.jinja",
2955 minijinja::context! {
2956 camel_method => camel_method,
2957 params => params,
2958 action_type => action_type,
2959 action_value => action_value,
2960 },
2961 );
2962 let _ = write!(decl, "{}", rendered);
2963}
2964
2965fn method_to_camel(snake: &str) -> String {
2967 use heck::ToUpperCamelCase;
2968 snake.to_upper_camel_case()
2969}
2970
2971fn snake_case_template_to_camel(template: &str) -> String {
2974 use heck::ToLowerCamelCase;
2975 let mut out = String::with_capacity(template.len());
2976 let mut chars = template.chars().peekable();
2977 while let Some(c) = chars.next() {
2978 if c == '{' {
2979 let mut name = String::new();
2980 while let Some(&nc) = chars.peek() {
2981 if nc == '}' {
2982 chars.next();
2983 break;
2984 }
2985 name.push(nc);
2986 chars.next();
2987 }
2988 out.push('{');
2989 out.push_str(&name.to_lower_camel_case());
2990 out.push('}');
2991 } else {
2992 out.push(c);
2993 }
2994 }
2995 out
2996}
2997
2998fn build_csharp_method_call(
3003 result_var: &str,
3004 method_name: &str,
3005 args: Option<&serde_json::Value>,
3006 class_name: &str,
3007) -> String {
3008 match method_name {
3009 "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
3010 "root_node_type" => format!("{result_var}.RootNode.Kind"),
3011 "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
3012 "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
3013 "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
3014 "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
3015 "contains_node_type" => {
3016 let node_type = args
3017 .and_then(|a| a.get("node_type"))
3018 .and_then(|v| v.as_str())
3019 .unwrap_or("");
3020 format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
3021 }
3022 "find_nodes_by_type" => {
3023 let node_type = args
3024 .and_then(|a| a.get("node_type"))
3025 .and_then(|v| v.as_str())
3026 .unwrap_or("");
3027 format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
3028 }
3029 "run_query" => {
3030 let query_source = args
3031 .and_then(|a| a.get("query_source"))
3032 .and_then(|v| v.as_str())
3033 .unwrap_or("");
3034 let language = args
3035 .and_then(|a| a.get("language"))
3036 .and_then(|v| v.as_str())
3037 .unwrap_or("");
3038 format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
3039 }
3040 _ => {
3041 use heck::ToUpperCamelCase;
3042 let pascal = method_name.to_upper_camel_case();
3043 format!("{result_var}.{pascal}()")
3044 }
3045 }
3046}
3047
3048fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
3049 if fixture.is_http_test() {
3051 return false;
3052 }
3053 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
3055 let cs_override = call_config
3056 .overrides
3057 .get("csharp")
3058 .or_else(|| e2e_config.call.overrides.get("csharp"));
3059 if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
3061 return true;
3062 }
3063 cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
3066}
3067
3068fn classify_bytes_value_csharp(s: &str) -> String {
3071 if let Some(first) = s.chars().next() {
3074 if first.is_ascii_alphanumeric() || first == '_' {
3075 if let Some(slash_pos) = s.find('/') {
3076 if slash_pos > 0 {
3077 let after_slash = &s[slash_pos + 1..];
3078 if after_slash.contains('.') && !after_slash.is_empty() {
3079 return format!("System.IO.File.ReadAllBytes(\"{}\")", s);
3081 }
3082 }
3083 }
3084 }
3085 }
3086
3087 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
3090 return format!("System.Text.Encoding.UTF8.GetBytes(\"{}\")", escape_csharp(s));
3092 }
3093
3094 format!("System.Convert.FromBase64String(\"{}\")", s)
3098}
3099
3100#[cfg(test)]
3101mod tests {
3102 use crate::config::{CallConfig, E2eConfig, SelectWhen};
3103 use crate::fixture::Fixture;
3104 use std::collections::HashMap;
3105
3106 fn make_fixture_with_input(id: &str, input: serde_json::Value) -> Fixture {
3107 Fixture {
3108 id: id.to_string(),
3109 category: None,
3110 description: "test fixture".to_string(),
3111 tags: vec![],
3112 skip: None,
3113 env: None,
3114 call: None,
3115 input,
3116 mock_response: None,
3117 source: String::new(),
3118 http: None,
3119 assertions: vec![],
3120 visitor: None,
3121 }
3122 }
3123
3124 #[test]
3127 fn test_csharp_select_when_routes_to_batch_scrape() {
3128 let mut calls = HashMap::new();
3129 calls.insert(
3130 "batch_scrape".to_string(),
3131 CallConfig {
3132 function: "BatchScrape".to_string(),
3133 module: "KreuzBrowser".to_string(),
3134 select_when: Some(SelectWhen::InputHas("batch_urls".to_string())),
3135 ..CallConfig::default()
3136 },
3137 );
3138
3139 let e2e_config = E2eConfig {
3140 call: CallConfig {
3141 function: "Scrape".to_string(),
3142 module: "KreuzBrowser".to_string(),
3143 ..CallConfig::default()
3144 },
3145 calls,
3146 ..E2eConfig::default()
3147 };
3148
3149 let fixture = make_fixture_with_input("batch_empty_urls", serde_json::json!({ "batch_urls": [] }));
3151
3152 let resolved_call = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
3153 assert_eq!(resolved_call.function, "BatchScrape");
3154
3155 let fixture_no_batch =
3157 make_fixture_with_input("simple_scrape", serde_json::json!({ "url": "https://example.com" }));
3158 let resolved_default =
3159 e2e_config.resolve_call_for_fixture(fixture_no_batch.call.as_deref(), &fixture_no_batch.input);
3160 assert_eq!(resolved_default.function, "Scrape");
3161 }
3162}