1use crate::codegen::client::{self, CallCtx};
7use crate::codegen::resolve_field;
8use crate::config::E2eConfig;
9use crate::escape::{escape_rust, rust_raw_string, sanitize_filename, sanitize_ident};
10use crate::field_access::FieldResolver;
11use crate::fixture::{
12 Assertion, CallbackAction, CorsConfig, Fixture, FixtureGroup, HttpFixture, StaticFilesConfig,
13 ValidationErrorExpectation,
14};
15use alef_core::backend::GeneratedFile;
16use alef_core::config::AlefConfig;
17use alef_core::hash::{self, CommentStyle};
18use alef_core::template_versions as tv;
19use anyhow::Result;
20use std::fmt::Write as FmtWrite;
21use std::path::PathBuf;
22
23pub struct RustE2eCodegen;
25
26impl super::E2eCodegen for RustE2eCodegen {
27 fn generate(
28 &self,
29 groups: &[FixtureGroup],
30 e2e_config: &E2eConfig,
31 alef_config: &AlefConfig,
32 ) -> Result<Vec<GeneratedFile>> {
33 let mut files = Vec::new();
34 let output_base = PathBuf::from(e2e_config.effective_output()).join("rust");
35
36 let crate_name = resolve_crate_name(e2e_config, alef_config);
38 let crate_path = resolve_crate_path(e2e_config, &crate_name);
39 let dep_name = crate_name.replace('-', "_");
40
41 let all_call_configs = std::iter::once(&e2e_config.call).chain(e2e_config.calls.values());
44 let needs_serde_json = all_call_configs
45 .flat_map(|c| c.args.iter())
46 .any(|a| a.arg_type == "json_object" || a.arg_type == "handle");
47
48 let needs_mock_server = groups
51 .iter()
52 .flat_map(|g| g.fixtures.iter())
53 .any(|f| !is_skipped(f, "rust") && f.mock_response.is_some());
54
55 let needs_http_tests = groups
57 .iter()
58 .flat_map(|g| g.fixtures.iter())
59 .any(|f| !is_skipped(f, "rust") && f.http.is_some());
60
61 let needs_tower_http = groups
63 .iter()
64 .flat_map(|g| g.fixtures.iter())
65 .filter(|f| !is_skipped(f, "rust"))
66 .filter_map(|f| f.http.as_ref())
67 .filter_map(|h| h.handler.middleware.as_ref())
68 .any(|m| m.cors.is_some() || m.static_files.is_some());
69
70 let any_async_call = std::iter::once(&e2e_config.call)
72 .chain(e2e_config.calls.values())
73 .any(|c| c.r#async);
74 let needs_tokio = needs_mock_server || needs_http_tests || any_async_call;
75
76 let crate_version = resolve_crate_version(e2e_config).or_else(|| alef_config.resolved_version());
77 files.push(GeneratedFile {
78 path: output_base.join("Cargo.toml"),
79 content: render_cargo_toml(
80 &crate_name,
81 &dep_name,
82 &crate_path,
83 needs_serde_json,
84 needs_mock_server,
85 needs_http_tests,
86 needs_tokio,
87 needs_tower_http,
88 e2e_config.dep_mode,
89 crate_version.as_deref(),
90 &alef_config.crate_config.features,
91 ),
92 generated_header: true,
93 });
94
95 if needs_mock_server {
97 files.push(GeneratedFile {
98 path: output_base.join("tests").join("mock_server.rs"),
99 content: render_mock_server_module(),
100 generated_header: true,
101 });
102 }
103 if needs_mock_server || needs_http_tests {
106 files.push(GeneratedFile {
107 path: output_base.join("src").join("main.rs"),
108 content: render_mock_server_binary(),
109 generated_header: true,
110 });
111 }
112
113 for group in groups {
115 let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
116
117 if fixtures.is_empty() {
118 continue;
119 }
120
121 let filename = format!("{}_test.rs", sanitize_filename(&group.category));
122 let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name, needs_mock_server);
123
124 files.push(GeneratedFile {
125 path: output_base.join("tests").join(filename),
126 content,
127 generated_header: true,
128 });
129 }
130
131 Ok(files)
132 }
133
134 fn language_name(&self) -> &'static str {
135 "rust"
136 }
137}
138
139fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
144 alef_config.crate_config.name.clone()
148}
149
150fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
151 e2e_config
152 .resolve_package("rust")
153 .and_then(|p| p.path.clone())
154 .unwrap_or_else(|| format!("../../crates/{crate_name}"))
155}
156
157fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
158 e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
159}
160
161fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
162 call_config
163 .overrides
164 .get("rust")
165 .and_then(|o| o.function.clone())
166 .unwrap_or_else(|| call_config.function.clone())
167}
168
169fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
170 resolve_module_for_call(&e2e_config.call, dep_name)
171}
172
173fn resolve_module_for_call(call_config: &crate::config::CallConfig, dep_name: &str) -> String {
174 let overrides = call_config.overrides.get("rust");
177 overrides
178 .and_then(|o| o.crate_name.clone())
179 .or_else(|| overrides.and_then(|o| o.module.clone()))
180 .unwrap_or_else(|| dep_name.to_string())
181}
182
183fn is_skipped(fixture: &Fixture, language: &str) -> bool {
184 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
185}
186
187#[allow(clippy::too_many_arguments)]
192pub fn render_cargo_toml(
193 crate_name: &str,
194 dep_name: &str,
195 crate_path: &str,
196 needs_serde_json: bool,
197 needs_mock_server: bool,
198 needs_http_tests: bool,
199 needs_tokio: bool,
200 needs_tower_http: bool,
201 dep_mode: crate::config::DependencyMode,
202 version: Option<&str>,
203 features: &[String],
204) -> String {
205 let e2e_name = format!("{dep_name}-e2e-rust");
206 let effective_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
210 let features_str = if effective_features.is_empty() {
211 String::new()
212 } else {
213 format!(", default-features = false, features = {:?}", effective_features)
214 };
215 let dep_spec = match dep_mode {
216 crate::config::DependencyMode::Registry => {
217 let ver = version.unwrap_or("0.1.0");
218 if crate_name != dep_name {
219 format!("{dep_name} = {{ package = \"{crate_name}\", version = \"{ver}\"{features_str} }}")
220 } else if effective_features.is_empty() {
221 format!("{dep_name} = \"{ver}\"")
222 } else {
223 format!("{dep_name} = {{ version = \"{ver}\"{features_str} }}")
224 }
225 }
226 crate::config::DependencyMode::Local => {
227 if crate_name != dep_name {
228 format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\"{features_str} }}")
229 } else if effective_features.is_empty() {
230 format!("{dep_name} = {{ path = \"{crate_path}\" }}")
231 } else {
232 format!("{dep_name} = {{ path = \"{crate_path}\"{features_str} }}")
233 }
234 }
235 };
236 let effective_needs_serde_json = needs_serde_json || needs_mock_server || needs_http_tests;
240 let serde_line = if effective_needs_serde_json {
241 "\nserde_json = \"1\""
242 } else {
243 ""
244 };
245 let needs_axum = needs_mock_server || needs_http_tests;
254 let mock_lines = if needs_axum {
255 let mut lines = format!(
256 "\naxum = \"{axum}\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\nwalkdir = \"{walkdir}\"",
257 axum = tv::cargo::AXUM,
258 walkdir = tv::cargo::WALKDIR,
259 );
260 if needs_mock_server {
261 lines.push_str(&format!(
262 "\ntokio-stream = \"{tokio_stream}\"",
263 tokio_stream = tv::cargo::TOKIO_STREAM
264 ));
265 }
266 if needs_http_tests {
267 lines.push_str("\naxum-test = \"20\"\nbytes = \"1\"");
268 }
269 if needs_tower_http {
270 lines.push_str(&format!(
271 "\ntower-http = {{ version = \"{tower_http}\", features = [\"cors\", \"fs\"] }}\ntempfile = \"{tempfile}\"",
272 tower_http = tv::cargo::TOWER_HTTP,
273 tempfile = tv::cargo::TEMPFILE,
274 ));
275 }
276 lines
277 } else {
278 String::new()
279 };
280 let mut machete_ignored: Vec<&str> = Vec::new();
281 if effective_needs_serde_json {
282 machete_ignored.push("\"serde_json\"");
283 }
284 if needs_axum {
285 machete_ignored.push("\"axum\"");
286 machete_ignored.push("\"serde\"");
287 machete_ignored.push("\"walkdir\"");
288 }
289 if needs_mock_server {
290 machete_ignored.push("\"tokio-stream\"");
291 }
292 if needs_http_tests {
293 machete_ignored.push("\"axum-test\"");
294 machete_ignored.push("\"bytes\"");
295 }
296 if needs_tower_http {
297 machete_ignored.push("\"tower-http\"");
298 machete_ignored.push("\"tempfile\"");
299 }
300 let machete_section = if machete_ignored.is_empty() {
301 String::new()
302 } else {
303 format!(
304 "\n[package.metadata.cargo-machete]\nignored = [{}]\n",
305 machete_ignored.join(", ")
306 )
307 };
308 let tokio_line = if needs_tokio {
309 "\ntokio = { version = \"1\", features = [\"full\"] }"
310 } else {
311 ""
312 };
313 let bin_section = if needs_mock_server || needs_http_tests {
314 "\n[[bin]]\nname = \"mock-server\"\npath = \"src/main.rs\"\n"
315 } else {
316 ""
317 };
318 let header = hash::header(CommentStyle::Hash);
319 format!(
320 r#"{header}
321[workspace]
322
323[package]
324name = "{e2e_name}"
325version = "0.1.0"
326edition = "2021"
327license = "MIT"
328publish = false
329{bin_section}
330[dependencies]
331{dep_spec}{serde_line}{mock_lines}{tokio_line}
332{machete_section}"#
333 )
334}
335
336fn render_test_file(
337 category: &str,
338 fixtures: &[&Fixture],
339 e2e_config: &E2eConfig,
340 dep_name: &str,
341 needs_mock_server: bool,
342) -> String {
343 let mut out = String::new();
344 out.push_str(&hash::header(CommentStyle::DoubleSlash));
345 let _ = writeln!(out, "//! E2e tests for category: {category}");
346 let _ = writeln!(out);
347
348 let module = resolve_module(e2e_config, dep_name);
349 let field_resolver = FieldResolver::new(
350 &e2e_config.fields,
351 &e2e_config.fields_optional,
352 &e2e_config.result_fields,
353 &e2e_config.fields_array,
354 );
355
356 let file_has_http = fixtures.iter().any(|f| f.http.is_some());
358 let is_call_based = |f: &Fixture| -> bool {
363 if f.http.is_some() {
364 return false;
365 }
366 let cc = e2e_config.resolve_call(f.call.as_deref());
367 !resolve_function_name_for_call(cc).is_empty()
368 };
369 let file_has_call_based = fixtures.iter().any(|f| is_call_based(f));
370
371 if file_has_call_based {
374 let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
375 for fixture in fixtures.iter().filter(|f| is_call_based(f)) {
376 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
377 let fn_name = resolve_function_name_for_call(call_config);
378 let mod_name = resolve_module_for_call(call_config, dep_name);
379 imported.insert((mod_name, fn_name));
380 }
381 let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
383 for (mod_name, fn_name) in &imported {
384 by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
385 }
386 for (mod_name, fns) in &by_module {
387 if fns.len() == 1 {
388 let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
389 } else {
390 let joined = fns.join(", ");
391 let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
392 }
393 }
394 }
395
396 if file_has_http {
398 let _ = writeln!(out, "use {module}::{{App, RequestContext}};");
399 }
400
401 let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
403 if has_handle_args {
404 let _ = writeln!(out, "use {module}::CrawlConfig;");
405 }
406 for arg in &e2e_config.call.args {
407 if arg.arg_type == "handle" {
408 use heck::ToSnakeCase;
409 let constructor_name = format!("create_{}", arg.name.to_snake_case());
410 let _ = writeln!(out, "use {module}::{constructor_name};");
411 }
412 }
413
414 let file_needs_mock = needs_mock_server && fixtures.iter().any(|f| f.mock_response.is_some());
416 if file_needs_mock {
417 let _ = writeln!(out, "mod mock_server;");
418 let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
419 }
420
421 {
425 let call_config = &e2e_config.call;
426 let rust_override = call_config.overrides.get("rust");
427 let needs_options_type_import =
428 rust_override.is_some_and(|o| o.wrap_options_in_some && o.options_type.is_some());
429 if needs_options_type_import {
430 let ty_name = rust_override.unwrap().options_type.as_deref().unwrap();
431 let file_has_non_null_options = fixtures.iter().any(|f| {
432 call_config
433 .args
434 .iter()
435 .any(|a| a.arg_type == "json_object" && a.optional && !resolve_field(&f.input, &a.field).is_null())
436 });
437 if file_has_non_null_options {
438 let _ = writeln!(out, "use {module}::{ty_name};");
439 }
440 }
441 }
442
443 let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
447 if file_needs_visitor {
448 let visitor_trait = resolve_visitor_trait(&module);
449 let trait_module = resolve_visitor_trait_module(&module);
450 if trait_module == module {
451 let _ = writeln!(out, "use {module}::{{{visitor_trait}, NodeContext, VisitResult}};");
452 } else {
453 let _ = writeln!(out, "use {trait_module}::{visitor_trait};");
455 let _ = writeln!(out, "use {module}::{{NodeContext, VisitResult}};");
456 }
457 }
458
459 let _ = writeln!(out);
460
461 for fixture in fixtures {
462 render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
463 let _ = writeln!(out);
464 }
465
466 if !out.ends_with('\n') {
467 out.push('\n');
468 }
469 out
470}
471
472fn render_test_function(
473 out: &mut String,
474 fixture: &Fixture,
475 e2e_config: &E2eConfig,
476 dep_name: &str,
477 field_resolver: &FieldResolver,
478) {
479 if fixture.http.is_some() {
481 render_http_test_function(out, fixture, dep_name);
482 return;
483 }
484
485 let fn_name = sanitize_ident(&fixture.id);
486 let description = &fixture.description;
487 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
488 let function_name = resolve_function_name_for_call(call_config);
489 let module = resolve_module_for_call(call_config, dep_name);
490
491 if fixture.http.is_none() && fixture.mock_response.is_none() && function_name.is_empty() {
495 let _ = writeln!(out, "#[tokio::test]");
496 let _ = writeln!(out, "async fn test_{fn_name}() {{");
497 let _ = writeln!(out, " // {description}");
498 let _ = writeln!(
499 out,
500 " // TODO: implement when a callable API is available for this fixture type."
501 );
502 let _ = writeln!(out, "}}");
503 return;
504 }
505 let result_var = &call_config.result_var;
506 let has_mock = fixture.mock_response.is_some();
507
508 let is_async = call_config.r#async || has_mock;
510 if is_async {
511 let _ = writeln!(out, "#[tokio::test]");
512 let _ = writeln!(out, "async fn test_{fn_name}() {{");
513 } else {
514 let _ = writeln!(out, "#[test]");
515 let _ = writeln!(out, "fn test_{fn_name}() {{");
516 }
517 let _ = writeln!(out, " // {description}");
518
519 if has_mock {
522 render_mock_server_setup(out, fixture, e2e_config);
523 }
524
525 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
527
528 let rust_overrides = call_config.overrides.get("rust");
530 let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
531 let options_type = rust_overrides.and_then(|o| o.options_type.as_deref());
532 let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
533
534 let mut arg_exprs: Vec<String> = Vec::new();
536 for arg in &call_config.args {
537 let value = resolve_field(&fixture.input, &arg.field);
538 let var_name = &arg.name;
539 let (bindings, expr) = render_rust_arg(
540 var_name,
541 value,
542 &arg.arg_type,
543 arg.optional,
544 &module,
545 &fixture.id,
546 if has_mock {
547 Some("mock_server.url.as_str()")
548 } else {
549 None
550 },
551 arg.owned,
552 arg.element_type.as_deref(),
553 );
554 let final_expr = if wrap_options_in_some && arg.arg_type == "json_object" {
560 if value.is_null() && arg.optional {
561 "None".to_string()
563 } else {
564 for binding in &bindings {
568 let annotated = if let Some(ty) = options_type {
569 binding.replace("serde_json::from_value(", &format!("serde_json::from_value::<{ty}>("))
571 } else {
572 binding.clone()
573 };
574 let _ = writeln!(out, " {annotated}");
575 }
576 if let Some(rest) = expr.strip_prefix('&') {
577 format!("Some({rest}.clone())")
578 } else {
579 format!("Some({expr})")
580 }
581 }
582 } else {
583 for binding in &bindings {
584 let _ = writeln!(out, " {binding}");
585 }
586 expr
587 };
588 arg_exprs.push(final_expr);
589 }
590
591 if let Some(visitor_spec) = &fixture.visitor {
593 let _ = writeln!(out, " #[derive(Debug)]");
594 let _ = writeln!(out, " struct _TestVisitor;");
595 let _ = writeln!(out, " impl {} for _TestVisitor {{", resolve_visitor_trait(&module));
596 for (method_name, action) in &visitor_spec.callbacks {
597 emit_rust_visitor_method(out, method_name, action);
598 }
599 let _ = writeln!(out, " }}");
600 let _ = writeln!(
601 out,
602 " let visitor = std::rc::Rc::new(std::cell::RefCell::new(_TestVisitor));"
603 );
604 arg_exprs.push("Some(visitor)".to_string());
605 } else {
606 arg_exprs.extend(extra_args);
609 }
610
611 let args_str = arg_exprs.join(", ");
612
613 let await_suffix = if is_async { ".await" } else { "" };
614
615 let result_is_tree = call_config.result_var == "tree";
616 let result_is_simple = call_config.result_is_simple || rust_overrides.is_some_and(|o| o.result_is_simple);
620 let result_is_vec = call_config.result_is_vec || rust_overrides.is_some_and(|o| o.result_is_vec);
621 let result_is_option = call_config.result_is_option || rust_overrides.is_some_and(|o| o.result_is_option);
624
625 if has_error_assertion {
626 let _ = writeln!(out, " let {result_var} = {function_name}({args_str}){await_suffix};");
627 for assertion in &fixture.assertions {
629 render_assertion(
630 out,
631 assertion,
632 result_var,
633 &module,
634 dep_name,
635 true,
636 &[],
637 field_resolver,
638 result_is_tree,
639 result_is_simple,
640 false,
641 false,
642 );
643 }
644 let _ = writeln!(out, "}}");
645 return;
646 }
647
648 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
650
651 let has_usable_assertion = fixture.assertions.iter().any(|a| {
655 if a.assertion_type == "not_error" || a.assertion_type == "error" {
656 return false;
657 }
658 if a.assertion_type == "method_result" {
659 let supported_checks = [
662 "equals",
663 "is_true",
664 "is_false",
665 "greater_than_or_equal",
666 "count_min",
667 "is_error",
668 "contains",
669 "not_empty",
670 "is_empty",
671 ];
672 let check = a.check.as_deref().unwrap_or("is_true");
673 if a.method.is_none() || !supported_checks.contains(&check) {
674 return false;
675 }
676 }
677 match &a.field {
678 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
679 _ => true,
680 }
681 });
682
683 let result_binding = if has_usable_assertion {
684 result_var.to_string()
685 } else {
686 "_".to_string()
687 };
688
689 let has_field_access = fixture
693 .assertions
694 .iter()
695 .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
696 let only_emptiness_checks = !has_field_access
697 && fixture.assertions.iter().all(|a| {
698 matches!(
699 a.assertion_type.as_str(),
700 "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
701 )
702 });
703
704 let returns_result = rust_overrides
707 .and_then(|o| o.returns_result)
708 .unwrap_or(call_config.returns_result);
709
710 let unwrap_suffix = if returns_result {
711 ".expect(\"should succeed\")"
712 } else {
713 ""
714 };
715 if only_emptiness_checks || !returns_result {
716 let _ = writeln!(
718 out,
719 " let {result_binding} = {function_name}({args_str}){await_suffix};"
720 );
721 } else if has_not_error || !fixture.assertions.is_empty() {
722 let _ = writeln!(
723 out,
724 " let {result_binding} = {function_name}({args_str}){await_suffix}{unwrap_suffix};"
725 );
726 } else {
727 let _ = writeln!(
728 out,
729 " let {result_binding} = {function_name}({args_str}){await_suffix};"
730 );
731 }
732
733 let string_assertion_types = [
739 "equals",
740 "contains",
741 "contains_all",
742 "contains_any",
743 "not_contains",
744 "starts_with",
745 "ends_with",
746 "min_length",
747 "max_length",
748 "matches_regex",
749 ];
750 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); if !result_is_vec {
752 for assertion in &fixture.assertions {
753 if let Some(f) = &assertion.field {
754 if !f.is_empty()
755 && string_assertion_types.contains(&assertion.assertion_type.as_str())
756 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
757 {
758 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
761 if !is_string_assertion {
762 continue;
763 }
764 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
765 let _ = writeln!(out, " {binding}");
766 unwrapped_fields.push((f.clone(), local_var));
767 }
768 }
769 }
770 }
771 }
772
773 for assertion in &fixture.assertions {
775 if assertion.assertion_type == "not_error" {
776 continue;
778 }
779 render_assertion(
780 out,
781 assertion,
782 result_var,
783 &module,
784 dep_name,
785 false,
786 &unwrapped_fields,
787 field_resolver,
788 result_is_tree,
789 result_is_simple,
790 result_is_vec,
791 result_is_option,
792 );
793 }
794
795 let _ = writeln!(out, "}}");
796}
797
798#[allow(clippy::too_many_arguments)]
803fn render_rust_arg(
804 name: &str,
805 value: &serde_json::Value,
806 arg_type: &str,
807 optional: bool,
808 module: &str,
809 fixture_id: &str,
810 mock_base_url: Option<&str>,
811 owned: bool,
812 element_type: Option<&str>,
813) -> (Vec<String>, String) {
814 if arg_type == "mock_url" {
815 let lines = vec![format!(
816 "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
817 )];
818 return (lines, format!("&{name}"));
819 }
820 if arg_type == "base_url" {
822 if let Some(url_expr) = mock_base_url {
823 return (vec![], url_expr.to_string());
824 }
825 }
827 if arg_type == "handle" {
828 use heck::ToSnakeCase;
832 let constructor_name = format!("create_{}", name.to_snake_case());
833 let mut lines = Vec::new();
834 if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
835 lines.push(format!(
836 "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
837 ));
838 } else {
839 let json_literal = serde_json::to_string(value).unwrap_or_default();
841 let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
842 lines.push(format!(
843 "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
844 ));
845 lines.push(format!(
846 "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
847 ));
848 }
849 return (lines, format!("&{name}"));
850 }
851 if arg_type == "json_object" {
852 return render_json_object_arg(name, value, optional, owned, element_type, module);
853 }
854 if arg_type == "bytes" && !optional {
859 if let Some(raw) = value.as_str() {
860 if matches!(classify_bytes_value(raw), BytesKind::FilePath) {
861 let lines = vec![format!(
862 "let {name} = std::fs::read(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../../test_documents/\", \"{raw}\")).expect(\"fixture file should exist\");"
863 )];
864 let pass = if owned { name.to_string() } else { format!("&{name}") };
865 return (lines, pass);
866 }
867 }
868 }
869 if arg_type == "bytes" && optional {
870 if let Some(raw) = value.as_str() {
871 if matches!(classify_bytes_value(raw), BytesKind::FilePath) {
872 let lines = vec![format!(
873 "let {name} = Some(std::fs::read(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../../test_documents/\", \"{raw}\")).expect(\"fixture file should exist\"));"
874 )];
875 let pass = if owned {
876 name.to_string()
877 } else {
878 format!("{name}.as_deref().map(|v| v.as_slice())")
879 };
880 return (lines, pass);
881 }
882 }
883 }
884 if value.is_null() && !optional {
885 let default_val = match arg_type {
887 "string" => "String::new()".to_string(),
888 "int" | "integer" => "0".to_string(),
889 "float" | "number" => "0.0_f64".to_string(),
890 "bool" | "boolean" => "false".to_string(),
891 _ => "Default::default()".to_string(),
892 };
893 let expr = if arg_type == "string" {
895 format!("&{name}")
896 } else {
897 name.to_string()
898 };
899 return (vec![format!("let {name} = {default_val};")], expr);
900 }
901 let literal = json_to_rust_literal(value, arg_type);
902 let pass_by_ref = arg_type == "bytes";
907 let optional_expr = |n: &str| {
908 if arg_type == "string" {
909 if owned {
910 n.to_string()
911 } else {
912 format!("{n}.as_deref()")
913 }
914 } else if arg_type == "bytes" {
915 if owned {
916 n.to_string()
917 } else {
918 format!("{n}.as_deref().map(|v| v.as_slice())")
919 }
920 } else {
921 n.to_string()
925 }
926 };
927 let expr = |n: &str| {
928 if arg_type == "bytes" {
929 if owned {
930 format!("{n}.as_bytes().to_vec()")
935 } else {
936 format!("{n}.as_bytes()")
937 }
938 } else if arg_type == "string" {
939 if owned {
940 format!("{n}.to_string()")
941 } else {
942 n.to_string()
943 }
944 } else if pass_by_ref {
945 format!("&{n}")
946 } else {
947 n.to_string()
948 }
949 };
950 if optional && value.is_null() {
951 let none_decl = match arg_type {
952 "string" if owned => format!("let {name}: Option<String> = None;"),
953 "string" => format!("let {name}: Option<String> = None;"),
954 "bytes" if owned => format!("let {name}: Option<Vec<u8>> = None;"),
955 "bytes" => format!("let {name}: Option<Vec<u8>> = None;"),
956 _ => format!("let {name} = None;"),
957 };
958 (vec![none_decl], optional_expr(name))
959 } else if optional {
960 if arg_type == "bytes" && owned {
962 (
963 vec![format!("let {name} = Some(({literal}).as_bytes().to_vec());")],
964 optional_expr(name),
965 )
966 } else if arg_type == "string" && owned {
967 (
968 vec![format!("let {name} = Some(({literal}).to_string());")],
969 optional_expr(name),
970 )
971 } else {
972 (vec![format!("let {name} = Some({literal});")], optional_expr(name))
973 }
974 } else {
975 (vec![format!("let {name} = {literal};")], expr(name))
976 }
977}
978
979#[derive(Debug, PartialEq, Eq)]
984enum BytesKind {
985 FilePath,
987 InlineText,
989 Base64,
992}
993
994fn classify_bytes_value(s: &str) -> BytesKind {
1003 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
1004 return BytesKind::InlineText;
1005 }
1006 let first = s.chars().next().unwrap_or('\0');
1007 if first.is_ascii_alphanumeric() || first == '_' {
1008 if let Some(slash_pos) = s.find('/') {
1009 if slash_pos > 0 {
1010 let after_slash = &s[slash_pos + 1..];
1011 if after_slash.contains('.') && !after_slash.is_empty() {
1012 return BytesKind::FilePath;
1013 }
1014 }
1015 }
1016 }
1017 BytesKind::Base64
1018}
1019
1020fn render_json_object_arg(
1028 name: &str,
1029 value: &serde_json::Value,
1030 optional: bool,
1031 owned: bool,
1032 element_type: Option<&str>,
1033 _module: &str,
1034) -> (Vec<String>, String) {
1035 let pass_by_ref = !owned;
1037
1038 if value.is_null() && optional {
1039 let expr = if pass_by_ref {
1041 format!("&{name}")
1042 } else {
1043 name.to_string()
1044 };
1045 return (vec![format!("let {name} = Default::default();")], expr);
1046 }
1047
1048 let normalized = super::normalize_json_keys_to_snake_case(value);
1051 let json_literal = json_value_to_macro_literal(&normalized);
1053 let mut lines = Vec::new();
1054 lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
1055
1056 let deser_expr = if let Some(elem) = element_type {
1059 format!("serde_json::from_value::<Vec<{elem}>>({name}_json).unwrap()")
1060 } else {
1061 format!("serde_json::from_value({name}_json).unwrap()")
1062 };
1063
1064 lines.push(format!("let {name} = {deser_expr};"));
1067 let expr = if pass_by_ref {
1068 format!("&{name}")
1069 } else {
1070 name.to_string()
1071 };
1072 (lines, expr)
1073}
1074
1075fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
1077 match value {
1078 serde_json::Value::Null => "null".to_string(),
1079 serde_json::Value::Bool(b) => format!("{b}"),
1080 serde_json::Value::Number(n) => n.to_string(),
1081 serde_json::Value::String(s) => {
1082 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
1083 format!("\"{escaped}\"")
1084 }
1085 serde_json::Value::Array(arr) => {
1086 let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
1087 format!("[{}]", items.join(", "))
1088 }
1089 serde_json::Value::Object(obj) => {
1090 let entries: Vec<String> = obj
1091 .iter()
1092 .map(|(k, v)| {
1093 let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
1094 format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
1095 })
1096 .collect();
1097 format!("{{{}}}", entries.join(", "))
1098 }
1099 }
1100}
1101
1102fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
1103 match value {
1104 serde_json::Value::Null => "None".to_string(),
1105 serde_json::Value::Bool(b) => format!("{b}"),
1106 serde_json::Value::Number(n) => {
1107 if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
1108 if let Some(f) = n.as_f64() {
1109 return format!("{f}_f64");
1110 }
1111 }
1112 n.to_string()
1113 }
1114 serde_json::Value::String(s) => rust_raw_string(s),
1115 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
1116 let json_str = serde_json::to_string(value).unwrap_or_default();
1117 let literal = rust_raw_string(&json_str);
1118 format!("serde_json::from_str({literal}).unwrap()")
1119 }
1120 }
1121}
1122
1123struct RustTestClientRenderer<'a> {
1134 dep_name: &'a str,
1135 http: &'a HttpFixture,
1136}
1137
1138impl<'a> client::TestClientRenderer for RustTestClientRenderer<'a> {
1139 fn language_name(&self) -> &'static str {
1140 "rust"
1141 }
1142
1143 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
1144 let _ = writeln!(out, "#[tokio::test]");
1145 if let Some(reason) = skip_reason {
1146 let _ = writeln!(out, "#[ignore = \"{reason}\"]");
1147 }
1148 let _ = writeln!(out, "async fn test_{fn_name}() {{");
1149 let _ = writeln!(out, " // {description}");
1150 }
1151
1152 fn render_test_close(&self, out: &mut String) {
1153 let _ = writeln!(out, "}}");
1154 }
1155
1156 fn render_call(&self, out: &mut String, ctx: &CallCtx<'_>) {
1157 let dep_name = self.dep_name;
1158 let http = self.http;
1159 let route = &http.handler.route;
1160 let status = http.expected_response.status_code;
1161
1162 let body_str = match &http.expected_response.body {
1164 Some(b) => serde_json::to_string(b).unwrap_or_else(|_| "{}".to_string()),
1165 None => String::new(),
1166 };
1167 let body_literal = rust_raw_string(&body_str);
1168
1169 let req_body_str = match &ctx.body {
1171 Some(b) => serde_json::to_string(b).unwrap_or_else(|_| "{}".to_string()),
1172 None => String::new(),
1173 };
1174 let has_req_body = !req_body_str.is_empty();
1175
1176 let route_reg = route_registration_for_method(&http.handler.method);
1177 let server_call = server_call_for_method(ctx.method);
1178 let cors_cfg = http.handler.middleware.as_ref().and_then(|m| m.cors.as_ref());
1179
1180 let _ = writeln!(out, " let expected_body = {body_literal}.to_string();");
1181 let _ = writeln!(out, " let mut app = {dep_name}::App::new();");
1182
1183 match &route_reg {
1185 RouteRegistration::Shorthand(method) => {
1186 let _ = writeln!(
1187 out,
1188 " app.route({dep_name}::{method}({route:?}), move |_ctx: {dep_name}::RequestContext| {{"
1189 );
1190 }
1191 RouteRegistration::Explicit(variant) => {
1192 let _ = writeln!(
1193 out,
1194 " app.route({dep_name}::RouteBuilder::new({dep_name}::Method::{variant}, {route:?}), move |_ctx: {dep_name}::RequestContext| {{"
1195 );
1196 }
1197 }
1198 let _ = writeln!(out, " let body = expected_body.clone();");
1199 let _ = writeln!(out, " async move {{");
1200 let _ = writeln!(out, " Ok(axum::http::Response::builder()");
1201 let _ = writeln!(out, " .status({status}u16)");
1202 let _ = writeln!(out, " .header(\"content-type\", \"application/json\")");
1203 let _ = writeln!(out, " .body(axum::body::Body::from(body))");
1204 let _ = writeln!(out, " .unwrap())");
1205 let _ = writeln!(out, " }}");
1206 let _ = writeln!(out, " }}).unwrap();");
1207
1208 let _ = writeln!(out, " let router = app.into_router().unwrap();");
1209 if let Some(cors) = cors_cfg {
1210 render_cors_layer(out, cors);
1211 }
1212 let _ = writeln!(out, " let server = axum_test::TestServer::new(router);");
1213
1214 let req_path = ctx.path;
1216 match &server_call {
1217 ServerCall::Shorthand(method) => {
1218 let _ = writeln!(out, " let {} = server.{method}({req_path:?})", ctx.response_var);
1219 }
1220 ServerCall::AxumMethod(method) => {
1221 let _ = writeln!(
1222 out,
1223 " let {} = server.method(axum::http::Method::{method}, {req_path:?})",
1224 ctx.response_var
1225 );
1226 }
1227 }
1228
1229 for (name, value) in ctx.headers {
1231 let n = rust_raw_string(name);
1232 let v = rust_raw_string(value);
1233 let _ = writeln!(out, " .add_header({n}, {v})");
1234 }
1235
1236 if has_req_body {
1238 let req_body_literal = rust_raw_string(&req_body_str);
1239 let _ = writeln!(
1240 out,
1241 " .bytes(bytes::Bytes::copy_from_slice({req_body_literal}.as_bytes()))"
1242 );
1243 }
1244
1245 let _ = writeln!(out, " .await;");
1246 }
1247
1248 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
1249 let cors_cfg = self.http.handler.middleware.as_ref().and_then(|m| m.cors.as_ref());
1250 if cors_cfg.is_some() && (200..300).contains(&status) {
1253 let _ = writeln!(
1254 out,
1255 " assert!({response_var}.status_code().is_success(), \"expected CORS success status, got {{}}\", {response_var}.status_code());"
1256 );
1257 } else {
1258 let _ = writeln!(
1259 out,
1260 " assert_eq!({response_var}.status_code().as_u16(), {status}u16);"
1261 );
1262 }
1263 }
1264
1265 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
1266 match expected {
1267 "<<present>>" => {
1268 let _ = writeln!(
1269 out,
1270 " assert!({response_var}.maybe_header({name:?}).is_some(), \"expected header {name} to be present\");"
1271 );
1272 }
1273 "<<absent>>" => {
1274 let _ = writeln!(
1275 out,
1276 " assert!({response_var}.maybe_header({name:?}).is_none(), \"expected header {name} to be absent\");"
1277 );
1278 }
1279 "<<uuid>>" => {
1280 let _ = writeln!(out, " {{");
1281 let _ = writeln!(
1282 out,
1283 " let _hval = {response_var}.maybe_header({name:?}).expect(\"expected header {name} to be present\");"
1284 );
1285 let _ = writeln!(
1286 out,
1287 " let _hstr = _hval.to_str().expect(\"header {name} should be valid UTF-8\");"
1288 );
1289 let _ = writeln!(
1290 out,
1291 " assert!(_hstr.len() == 36 && _hstr.chars().filter(|c| *c == '-').count() == 4, \"expected header {name} to be a UUID, got {{_hstr}}\");"
1292 );
1293 let _ = writeln!(out, " }}");
1294 }
1295 _ => {
1296 let _ = writeln!(
1297 out,
1298 " assert_eq!({response_var}.maybe_header({name:?}).and_then(|v| v.to_str().ok()), Some({expected:?}), \"header {name} mismatch\");"
1299 );
1300 }
1301 }
1302 }
1303
1304 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
1305 let expected_str = serde_json::to_string(expected).unwrap_or_else(|_| "{}".to_string());
1306 let expected_literal = rust_raw_string(&expected_str);
1307 let _ = writeln!(out, " {{");
1308 let _ = writeln!(out, " let body: serde_json::Value = {response_var}.json();");
1309 let _ = writeln!(
1310 out,
1311 " let expected: serde_json::Value = serde_json::from_str({expected_literal}).unwrap();"
1312 );
1313 let _ = writeln!(out, " assert_eq!(body, expected, \"response body mismatch\");");
1314 let _ = writeln!(out, " }}");
1315 }
1316
1317 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
1318 let expected_str = serde_json::to_string(expected).unwrap_or_else(|_| "{}".to_string());
1319 let expected_literal = rust_raw_string(&expected_str);
1320 let _ = writeln!(out, " {{");
1321 let _ = writeln!(out, " let body: serde_json::Value = {response_var}.json();");
1322 let _ = writeln!(
1323 out,
1324 " let partial: serde_json::Value = serde_json::from_str({expected_literal}).unwrap();"
1325 );
1326 let _ = writeln!(
1327 out,
1328 " if let (serde_json::Value::Object(body_map), serde_json::Value::Object(partial_map)) = (&body, &partial) {{"
1329 );
1330 let _ = writeln!(out, " for (k, v) in partial_map {{");
1331 let _ = writeln!(
1332 out,
1333 " assert_eq!(body_map.get(k), Some(v), \"partial body field {{k}} mismatch\");"
1334 );
1335 let _ = writeln!(out, " }}");
1336 let _ = writeln!(out, " }} else {{");
1337 let _ = writeln!(out, " assert_eq!(body, partial, \"partial body mismatch\");");
1338 let _ = writeln!(out, " }}");
1339 let _ = writeln!(out, " }}");
1340 }
1341
1342 fn render_assert_validation_errors(
1343 &self,
1344 out: &mut String,
1345 response_var: &str,
1346 errors: &[ValidationErrorExpectation],
1347 ) {
1348 let _ = writeln!(out, " {{");
1349 let _ = writeln!(out, " let body: serde_json::Value = {response_var}.json();");
1350 let _ = writeln!(
1351 out,
1352 " let detail = body.get(\"detail\").expect(\"validation error response must have 'detail' field\");"
1353 );
1354 let _ = writeln!(
1355 out,
1356 " let errs = detail.as_array().expect(\"'detail' must be an array\");"
1357 );
1358 let _ = writeln!(
1359 out,
1360 " assert_eq!(errs.len(), {}, \"expected {} validation error(s)\");",
1361 errors.len(),
1362 errors.len()
1363 );
1364 for (i, err) in errors.iter().enumerate() {
1365 let loc_json = serde_json::to_string(&err.loc).unwrap_or_else(|_| "[]".to_string());
1366 let loc_literal = rust_raw_string(&loc_json);
1367 let msg_escaped = escape_rust(&err.msg);
1368 let type_escaped = escape_rust(&err.error_type);
1369 let _ = writeln!(out, " {{");
1370 let _ = writeln!(out, " let e = &errs[{i}];");
1371 let _ = writeln!(
1372 out,
1373 " let loc: serde_json::Value = serde_json::from_str({loc_literal}).unwrap();"
1374 );
1375 let _ = writeln!(
1376 out,
1377 " assert_eq!(e.get(\"loc\"), Some(&loc), \"validation error [{i}] loc mismatch\");"
1378 );
1379 let _ = writeln!(
1380 out,
1381 " assert_eq!(e.get(\"msg\").and_then(|v| v.as_str()), Some(\"{msg_escaped}\"), \"validation error [{i}] msg mismatch\");"
1382 );
1383 let _ = writeln!(
1384 out,
1385 " assert_eq!(e.get(\"type\").and_then(|v| v.as_str()), Some(\"{type_escaped}\"), \"validation error [{i}] type mismatch\");"
1386 );
1387 let _ = writeln!(out, " }}");
1388 }
1389 let _ = writeln!(out, " }}");
1390 }
1391}
1392
1393enum ServerCall<'a> {
1399 Shorthand(&'a str),
1401 AxumMethod(&'a str),
1403}
1404
1405enum RouteRegistration<'a> {
1411 Shorthand(&'a str),
1413 Explicit(&'a str),
1415}
1416
1417fn route_registration_for_method(method: &str) -> RouteRegistration<'static> {
1419 match method.to_lowercase().as_str() {
1420 "get" => RouteRegistration::Shorthand("get"),
1421 "post" => RouteRegistration::Shorthand("post"),
1422 "put" => RouteRegistration::Shorthand("put"),
1423 "patch" => RouteRegistration::Shorthand("patch"),
1424 "delete" => RouteRegistration::Shorthand("delete"),
1425 "head" => RouteRegistration::Explicit("Head"),
1426 "options" => RouteRegistration::Explicit("Options"),
1427 "trace" => RouteRegistration::Explicit("Trace"),
1428 _ => RouteRegistration::Shorthand("get"),
1429 }
1430}
1431
1432fn server_call_for_method(method: &str) -> ServerCall<'static> {
1434 match method.to_uppercase().as_str() {
1435 "GET" => ServerCall::Shorthand("get"),
1436 "POST" => ServerCall::Shorthand("post"),
1437 "PUT" => ServerCall::Shorthand("put"),
1438 "PATCH" => ServerCall::Shorthand("patch"),
1439 "DELETE" => ServerCall::Shorthand("delete"),
1440 "HEAD" => ServerCall::AxumMethod("HEAD"),
1441 "OPTIONS" => ServerCall::AxumMethod("OPTIONS"),
1442 "TRACE" => ServerCall::AxumMethod("TRACE"),
1443 _ => ServerCall::Shorthand("get"),
1444 }
1445}
1446
1447fn render_http_test_function(out: &mut String, fixture: &Fixture, dep_name: &str) {
1453 let http = match &fixture.http {
1454 Some(h) => h,
1455 None => return,
1456 };
1457
1458 let static_files_cfgs: Option<&Vec<StaticFilesConfig>> =
1461 http.handler.middleware.as_ref().and_then(|m| m.static_files.as_ref());
1462 if static_files_cfgs.is_some_and(|v| !v.is_empty()) {
1463 let fn_name = sanitize_ident(&fixture.id);
1464 let description = &fixture.description;
1465 let req_path = &http.request.path;
1466 let status = http.expected_response.status_code;
1467 let server_call = server_call_for_method(&http.request.method);
1468 let _ = writeln!(out, "#[tokio::test]");
1469 let _ = writeln!(out, "async fn test_{fn_name}() {{");
1470 let _ = writeln!(out, " // {description}");
1471 render_static_files_test(out, fixture, static_files_cfgs.unwrap(), &server_call, req_path, status);
1472 return;
1473 }
1474
1475 let renderer = RustTestClientRenderer { dep_name, http };
1476 client::http_call::render_http_test(out, &renderer, fixture);
1477}
1478
1479fn render_cors_layer(out: &mut String, cors: &CorsConfig) {
1484 let _ = writeln!(
1485 out,
1486 " // Apply CorsLayer from tower-http based on fixture CORS config."
1487 );
1488 let _ = writeln!(out, " use tower_http::cors::CorsLayer;");
1489 let _ = writeln!(out, " use axum::http::{{HeaderName, HeaderValue, Method}};");
1490 let _ = writeln!(out, " let cors_layer = CorsLayer::new()");
1491
1492 if cors.allow_origins.is_empty() {
1494 let _ = writeln!(out, " .allow_origin(tower_http::cors::Any)");
1495 } else {
1496 let _ = writeln!(out, " .allow_origin([");
1497 for origin in &cors.allow_origins {
1498 let _ = writeln!(out, " \"{origin}\".parse::<HeaderValue>().unwrap(),");
1499 }
1500 let _ = writeln!(out, " ])");
1501 }
1502
1503 if cors.allow_methods.is_empty() {
1505 let _ = writeln!(out, " .allow_methods(tower_http::cors::Any)");
1506 } else {
1507 let methods: Vec<String> = cors
1508 .allow_methods
1509 .iter()
1510 .map(|m| format!("Method::{}", m.to_uppercase()))
1511 .collect();
1512 let _ = writeln!(out, " .allow_methods([{}])", methods.join(", "));
1513 }
1514
1515 if cors.allow_headers.is_empty() {
1517 let _ = writeln!(out, " .allow_headers(tower_http::cors::Any)");
1518 } else {
1519 let headers: Vec<String> = cors
1520 .allow_headers
1521 .iter()
1522 .map(|h| {
1523 let lower = h.to_lowercase();
1524 match lower.as_str() {
1525 "content-type" => "axum::http::header::CONTENT_TYPE".to_string(),
1526 "authorization" => "axum::http::header::AUTHORIZATION".to_string(),
1527 "accept" => "axum::http::header::ACCEPT".to_string(),
1528 _ => format!("HeaderName::from_static(\"{lower}\")"),
1529 }
1530 })
1531 .collect();
1532 let _ = writeln!(out, " .allow_headers([{}])", headers.join(", "));
1533 }
1534
1535 if let Some(secs) = cors.max_age {
1537 let _ = writeln!(out, " .max_age(std::time::Duration::from_secs({secs}));");
1538 } else {
1539 let _ = writeln!(out, " ;");
1540 }
1541
1542 let _ = writeln!(out, " let router = router.layer(cors_layer);");
1543}
1544
1545fn render_static_files_test(
1550 out: &mut String,
1551 fixture: &Fixture,
1552 cfgs: &[StaticFilesConfig],
1553 server_call: &ServerCall<'_>,
1554 req_path: &str,
1555 status: u16,
1556) {
1557 let http = fixture.http.as_ref().unwrap();
1558
1559 let _ = writeln!(out, " use tower_http::services::ServeDir;");
1560 let _ = writeln!(out, " use axum::Router;");
1561 let _ = writeln!(out, " let tmp_dir = tempfile::tempdir().expect(\"tmp dir\");");
1562
1563 let _ = writeln!(out, " let mut router = Router::new();");
1565 for cfg in cfgs {
1566 for file in &cfg.files {
1567 let file_path = file.path.replace('\\', "/");
1568 let content = rust_raw_string(&file.content);
1569 if file_path.contains('/') {
1570 let parent: String = file_path.rsplitn(2, '/').last().unwrap_or("").to_string();
1571 let _ = writeln!(
1572 out,
1573 " std::fs::create_dir_all(tmp_dir.path().join(\"{parent}\")).unwrap();"
1574 );
1575 }
1576 let _ = writeln!(
1577 out,
1578 " std::fs::write(tmp_dir.path().join(\"{file_path}\"), {content}).unwrap();"
1579 );
1580 }
1581 let prefix = &cfg.route_prefix;
1582 let serve_dir_expr = if cfg.index_file {
1583 "ServeDir::new(tmp_dir.path()).append_index_html_on_directories(true)".to_string()
1584 } else {
1585 "ServeDir::new(tmp_dir.path())".to_string()
1586 };
1587 let _ = writeln!(out, " router = router.nest_service({prefix:?}, {serve_dir_expr});");
1588 }
1589
1590 let _ = writeln!(out, " let server = axum_test::TestServer::new(router);");
1591
1592 match server_call {
1594 ServerCall::Shorthand(method) => {
1595 let _ = writeln!(out, " let response = server.{method}({req_path:?})");
1596 }
1597 ServerCall::AxumMethod(method) => {
1598 let _ = writeln!(
1599 out,
1600 " let response = server.method(axum::http::Method::{method}, {req_path:?})"
1601 );
1602 }
1603 }
1604
1605 for (name, value) in &http.request.headers {
1607 let n = rust_raw_string(name);
1608 let v = rust_raw_string(value);
1609 let _ = writeln!(out, " .add_header({n}, {v})");
1610 }
1611
1612 let _ = writeln!(out, " .await;");
1613 let _ = writeln!(out, " assert_eq!(response.status_code().as_u16(), {status}u16);");
1614 let _ = writeln!(out, "}}");
1615}
1616
1617fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
1627 let mock = match fixture.mock_response.as_ref() {
1628 Some(m) => m,
1629 None => return,
1630 };
1631
1632 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
1634 let path = call_config.path.as_deref().unwrap_or("/");
1635 let method = call_config.method.as_deref().unwrap_or("POST");
1636
1637 let status = mock.status;
1638
1639 let mut header_entries: Vec<(&String, &String)> = mock.headers.iter().collect();
1641 header_entries.sort_by(|a, b| a.0.cmp(b.0));
1642 let render_headers = |out: &mut String| {
1643 let _ = writeln!(out, " headers: vec![");
1644 for (name, value) in &header_entries {
1645 let n = rust_raw_string(name);
1646 let v = rust_raw_string(value);
1647 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
1648 }
1649 let _ = writeln!(out, " ],");
1650 };
1651
1652 if let Some(chunks) = &mock.stream_chunks {
1653 let _ = writeln!(out, " let mock_route = MockRoute {{");
1655 let _ = writeln!(out, " path: \"{path}\",");
1656 let _ = writeln!(out, " method: \"{method}\",");
1657 let _ = writeln!(out, " status: {status},");
1658 let _ = writeln!(out, " body: String::new(),");
1659 let _ = writeln!(out, " stream_chunks: vec![");
1660 for chunk in chunks {
1661 let chunk_str = match chunk {
1662 serde_json::Value::String(s) => rust_raw_string(s),
1663 other => {
1664 let s = serde_json::to_string(other).unwrap_or_default();
1665 rust_raw_string(&s)
1666 }
1667 };
1668 let _ = writeln!(out, " {chunk_str}.to_string(),");
1669 }
1670 let _ = writeln!(out, " ],");
1671 render_headers(out);
1672 let _ = writeln!(out, " }};");
1673 } else {
1674 let body_str = match &mock.body {
1676 Some(b) => {
1677 let s = serde_json::to_string(b).unwrap_or_default();
1678 rust_raw_string(&s)
1679 }
1680 None => rust_raw_string("{}"),
1681 };
1682 let _ = writeln!(out, " let mock_route = MockRoute {{");
1683 let _ = writeln!(out, " path: \"{path}\",");
1684 let _ = writeln!(out, " method: \"{method}\",");
1685 let _ = writeln!(out, " status: {status},");
1686 let _ = writeln!(out, " body: {body_str}.to_string(),");
1687 let _ = writeln!(out, " stream_chunks: vec![],");
1688 render_headers(out);
1689 let _ = writeln!(out, " }};");
1690 }
1691
1692 let _ = writeln!(out, " let mock_server = MockServer::start(vec![mock_route]).await;");
1693}
1694
1695pub fn render_mock_server_module() -> String {
1697 hash::header(CommentStyle::DoubleSlash)
1700 + r#"//
1701// Minimal axum-based mock HTTP server for e2e tests.
1702
1703use std::net::SocketAddr;
1704use std::sync::Arc;
1705
1706use axum::Router;
1707use axum::body::Body;
1708use axum::extract::State;
1709use axum::http::{Request, StatusCode};
1710use axum::response::{IntoResponse, Response};
1711use tokio::net::TcpListener;
1712
1713/// A single mock route: match by path + method, return a configured response.
1714#[derive(Clone, Debug)]
1715pub struct MockRoute {
1716 /// URL path to match, e.g. `"/v1/chat/completions"`.
1717 pub path: &'static str,
1718 /// HTTP method to match, e.g. `"POST"` or `"GET"`.
1719 pub method: &'static str,
1720 /// HTTP status code to return.
1721 pub status: u16,
1722 /// Response body JSON string (used when `stream_chunks` is empty).
1723 pub body: String,
1724 /// Ordered SSE data payloads for streaming responses.
1725 /// Each entry becomes `data: <chunk>\n\n` in the response.
1726 /// A final `data: [DONE]\n\n` is always appended.
1727 pub stream_chunks: Vec<String>,
1728 /// Response headers to apply (name, value) pairs.
1729 /// Multiple entries with the same name produce multiple header lines.
1730 pub headers: Vec<(String, String)>,
1731}
1732
1733struct ServerState {
1734 routes: Vec<MockRoute>,
1735}
1736
1737pub struct MockServer {
1738 /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
1739 pub url: String,
1740 handle: tokio::task::JoinHandle<()>,
1741}
1742
1743impl MockServer {
1744 /// Start a mock server with the given routes. Binds to a random port on
1745 /// localhost and returns immediately once the server is listening.
1746 pub async fn start(routes: Vec<MockRoute>) -> Self {
1747 let state = Arc::new(ServerState { routes });
1748
1749 let app = Router::new().fallback(handle_request).with_state(state);
1750
1751 let listener = TcpListener::bind("127.0.0.1:0")
1752 .await
1753 .expect("Failed to bind mock server port");
1754 let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
1755 let url = format!("http://{addr}");
1756
1757 let handle = tokio::spawn(async move {
1758 axum::serve(listener, app).await.expect("Mock server failed");
1759 });
1760
1761 MockServer { url, handle }
1762 }
1763
1764 /// Stop the mock server.
1765 pub fn shutdown(self) {
1766 self.handle.abort();
1767 }
1768}
1769
1770impl Drop for MockServer {
1771 fn drop(&mut self) {
1772 self.handle.abort();
1773 }
1774}
1775
1776async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
1777 let path = req.uri().path().to_owned();
1778 let method = req.method().as_str().to_uppercase();
1779
1780 for route in &state.routes {
1781 if route.path == path && route.method.to_uppercase() == method {
1782 let status =
1783 StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1784
1785 if !route.stream_chunks.is_empty() {
1786 // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
1787 let mut sse = String::new();
1788 for chunk in &route.stream_chunks {
1789 sse.push_str("data: ");
1790 sse.push_str(chunk);
1791 sse.push_str("\n\n");
1792 }
1793 sse.push_str("data: [DONE]\n\n");
1794
1795 let mut builder = Response::builder()
1796 .status(status)
1797 .header("content-type", "text/event-stream")
1798 .header("cache-control", "no-cache");
1799 for (name, value) in &route.headers {
1800 builder = builder.header(name, value);
1801 }
1802 return builder.body(Body::from(sse)).unwrap().into_response();
1803 }
1804
1805 let mut builder =
1806 Response::builder().status(status).header("content-type", "application/json");
1807 for (name, value) in &route.headers {
1808 builder = builder.header(name, value);
1809 }
1810 return builder.body(Body::from(route.body.clone())).unwrap().into_response();
1811 }
1812 }
1813
1814 // No matching route → 404.
1815 Response::builder()
1816 .status(StatusCode::NOT_FOUND)
1817 .body(Body::from(format!("No mock route for {method} {path}")))
1818 .unwrap()
1819 .into_response()
1820}
1821"#
1822}
1823
1824pub fn render_mock_server_binary() -> String {
1836 hash::header(CommentStyle::DoubleSlash)
1837 + r#"//
1838// Standalone mock HTTP server binary for cross-language e2e tests.
1839// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
1840//
1841// Usage: mock-server [fixtures-dir]
1842// fixtures-dir defaults to "../../fixtures"
1843//
1844// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
1845// then blocks until stdin is closed (parent process exit triggers cleanup).
1846
1847use std::collections::HashMap;
1848use std::io::{self, BufRead};
1849use std::net::SocketAddr;
1850use std::path::Path;
1851use std::sync::Arc;
1852
1853use axum::Router;
1854use axum::body::Body;
1855use axum::extract::State;
1856use axum::http::{Request, StatusCode};
1857use axum::response::{IntoResponse, Response};
1858use serde::Deserialize;
1859use tokio::net::TcpListener;
1860
1861// ---------------------------------------------------------------------------
1862// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
1863// Supports both schemas:
1864// mock_response: { status, body, stream_chunks }
1865// http.expected_response: { status_code, body, headers }
1866// ---------------------------------------------------------------------------
1867
1868#[derive(Debug, Deserialize)]
1869struct MockResponse {
1870 status: u16,
1871 #[serde(default)]
1872 body: Option<serde_json::Value>,
1873 #[serde(default)]
1874 stream_chunks: Option<Vec<serde_json::Value>>,
1875 #[serde(default)]
1876 headers: HashMap<String, String>,
1877}
1878
1879#[derive(Debug, Deserialize)]
1880struct HttpExpectedResponse {
1881 status_code: u16,
1882 #[serde(default)]
1883 body: Option<serde_json::Value>,
1884 #[serde(default)]
1885 headers: HashMap<String, String>,
1886}
1887
1888#[derive(Debug, Deserialize)]
1889struct HttpFixture {
1890 expected_response: HttpExpectedResponse,
1891}
1892
1893#[derive(Debug, Deserialize)]
1894struct Fixture {
1895 id: String,
1896 #[serde(default)]
1897 mock_response: Option<MockResponse>,
1898 #[serde(default)]
1899 http: Option<HttpFixture>,
1900}
1901
1902impl Fixture {
1903 /// Bridge both schemas into a unified MockResponse.
1904 fn as_mock_response(&self) -> Option<MockResponse> {
1905 if let Some(mock) = &self.mock_response {
1906 return Some(MockResponse {
1907 status: mock.status,
1908 body: mock.body.clone(),
1909 stream_chunks: mock.stream_chunks.clone(),
1910 headers: mock.headers.clone(),
1911 });
1912 }
1913 if let Some(http) = &self.http {
1914 return Some(MockResponse {
1915 status: http.expected_response.status_code,
1916 body: http.expected_response.body.clone(),
1917 stream_chunks: None,
1918 headers: http.expected_response.headers.clone(),
1919 });
1920 }
1921 None
1922 }
1923}
1924
1925// ---------------------------------------------------------------------------
1926// Route table
1927// ---------------------------------------------------------------------------
1928
1929#[derive(Clone, Debug)]
1930struct MockRoute {
1931 status: u16,
1932 body: String,
1933 stream_chunks: Vec<String>,
1934 headers: Vec<(String, String)>,
1935}
1936
1937type RouteTable = Arc<HashMap<String, MockRoute>>;
1938
1939// ---------------------------------------------------------------------------
1940// Axum handler
1941// ---------------------------------------------------------------------------
1942
1943async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
1944 let path = req.uri().path().to_owned();
1945
1946 // Try exact match first
1947 if let Some(route) = routes.get(&path) {
1948 return serve_route(route);
1949 }
1950
1951 // Try prefix match: find a route that is a prefix of the request path
1952 // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
1953 for (route_path, route) in routes.iter() {
1954 if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
1955 return serve_route(route);
1956 }
1957 }
1958
1959 Response::builder()
1960 .status(StatusCode::NOT_FOUND)
1961 .body(Body::from(format!("No mock route for {path}")))
1962 .unwrap()
1963 .into_response()
1964}
1965
1966fn serve_route(route: &MockRoute) -> Response {
1967 let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1968
1969 if !route.stream_chunks.is_empty() {
1970 let mut sse = String::new();
1971 for chunk in &route.stream_chunks {
1972 sse.push_str("data: ");
1973 sse.push_str(chunk);
1974 sse.push_str("\n\n");
1975 }
1976 sse.push_str("data: [DONE]\n\n");
1977
1978 let mut builder = Response::builder()
1979 .status(status)
1980 .header("content-type", "text/event-stream")
1981 .header("cache-control", "no-cache");
1982 for (name, value) in &route.headers {
1983 builder = builder.header(name, value);
1984 }
1985 return builder.body(Body::from(sse)).unwrap().into_response();
1986 }
1987
1988 // Only set the default content-type if the fixture does not override it.
1989 // Use application/json when the body looks like JSON (starts with { or [),
1990 // otherwise fall back to text/plain to avoid clients failing JSON-decode.
1991 let has_content_type = route.headers.iter().any(|(k, _)| k.to_lowercase() == "content-type");
1992 let mut builder = Response::builder().status(status);
1993 if !has_content_type {
1994 let trimmed = route.body.trim_start();
1995 let default_ct = if trimmed.starts_with('{') || trimmed.starts_with('[') {
1996 "application/json"
1997 } else {
1998 "text/plain"
1999 };
2000 builder = builder.header("content-type", default_ct);
2001 }
2002 for (name, value) in &route.headers {
2003 // Skip content-encoding headers — the mock server returns uncompressed bodies.
2004 // Sending a content-encoding without actually encoding the body would cause
2005 // clients to fail decompression.
2006 if name.to_lowercase() == "content-encoding" {
2007 continue;
2008 }
2009 // The <<absent>> sentinel means this header must NOT be present in the
2010 // real server response — do not emit it from the mock server either.
2011 if value == "<<absent>>" {
2012 continue;
2013 }
2014 // Replace the <<uuid>> sentinel with a real UUID v4 so clients can
2015 // assert the header value matches the UUID pattern.
2016 if value == "<<uuid>>" {
2017 let uuid = format!(
2018 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
2019 rand_u32(),
2020 rand_u16(),
2021 rand_u16() & 0x0fff,
2022 (rand_u16() & 0x3fff) | 0x8000,
2023 rand_u48(),
2024 );
2025 builder = builder.header(name, uuid);
2026 continue;
2027 }
2028 builder = builder.header(name, value);
2029 }
2030 builder.body(Body::from(route.body.clone())).unwrap().into_response()
2031}
2032
2033/// Generate a pseudo-random u32 using the current time nanoseconds.
2034fn rand_u32() -> u32 {
2035 use std::time::{SystemTime, UNIX_EPOCH};
2036 let ns = SystemTime::now()
2037 .duration_since(UNIX_EPOCH)
2038 .map(|d| d.subsec_nanos())
2039 .unwrap_or(0);
2040 ns ^ (ns.wrapping_shl(13)) ^ (ns.wrapping_shr(17))
2041}
2042
2043fn rand_u16() -> u16 {
2044 (rand_u32() & 0xffff) as u16
2045}
2046
2047fn rand_u48() -> u64 {
2048 ((rand_u32() as u64) << 16) | (rand_u16() as u64)
2049}
2050
2051// ---------------------------------------------------------------------------
2052// Fixture loading
2053// ---------------------------------------------------------------------------
2054
2055fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
2056 let mut routes = HashMap::new();
2057 load_routes_recursive(fixtures_dir, &mut routes);
2058 routes
2059}
2060
2061fn load_routes_recursive(dir: &Path, routes: &mut HashMap<String, MockRoute>) {
2062 let entries = match std::fs::read_dir(dir) {
2063 Ok(e) => e,
2064 Err(err) => {
2065 eprintln!("warning: cannot read directory {}: {err}", dir.display());
2066 return;
2067 }
2068 };
2069
2070 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
2071 paths.sort();
2072
2073 for path in paths {
2074 if path.is_dir() {
2075 load_routes_recursive(&path, routes);
2076 } else if path.extension().is_some_and(|ext| ext == "json") {
2077 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
2078 if filename == "schema.json" || filename.starts_with('_') {
2079 continue;
2080 }
2081 let content = match std::fs::read_to_string(&path) {
2082 Ok(c) => c,
2083 Err(err) => {
2084 eprintln!("warning: cannot read {}: {err}", path.display());
2085 continue;
2086 }
2087 };
2088 let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
2089 match serde_json::from_str(&content) {
2090 Ok(v) => v,
2091 Err(err) => {
2092 eprintln!("warning: cannot parse {}: {err}", path.display());
2093 continue;
2094 }
2095 }
2096 } else {
2097 match serde_json::from_str::<Fixture>(&content) {
2098 Ok(f) => vec![f],
2099 Err(err) => {
2100 eprintln!("warning: cannot parse {}: {err}", path.display());
2101 continue;
2102 }
2103 }
2104 };
2105
2106 for fixture in fixtures {
2107 if let Some(mock) = fixture.as_mock_response() {
2108 let route_path = format!("/fixtures/{}", fixture.id);
2109 let body = mock
2110 .body
2111 .as_ref()
2112 .map(|b| match b {
2113 // Plain strings (e.g. text/plain bodies) are stored as JSON strings in
2114 // fixtures. Return the raw value so clients receive the string itself,
2115 // not its JSON-encoded form with extra surrounding quotes.
2116 serde_json::Value::String(s) => s.clone(),
2117 other => serde_json::to_string(other).unwrap_or_default(),
2118 })
2119 .unwrap_or_default();
2120 let stream_chunks = mock
2121 .stream_chunks
2122 .unwrap_or_default()
2123 .into_iter()
2124 .map(|c| match c {
2125 serde_json::Value::String(s) => s,
2126 other => serde_json::to_string(&other).unwrap_or_default(),
2127 })
2128 .collect();
2129 let mut headers: Vec<(String, String)> =
2130 mock.headers.into_iter().collect();
2131 headers.sort_by(|a, b| a.0.cmp(&b.0));
2132 routes.insert(route_path, MockRoute { status: mock.status, body, stream_chunks, headers });
2133 }
2134 }
2135 }
2136 }
2137}
2138
2139// ---------------------------------------------------------------------------
2140// Entry point
2141// ---------------------------------------------------------------------------
2142
2143#[tokio::main]
2144async fn main() {
2145 let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
2146 let fixtures_dir = Path::new(&fixtures_dir_arg);
2147
2148 let routes = load_routes(fixtures_dir);
2149 eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
2150
2151 let route_table: RouteTable = Arc::new(routes);
2152 let app = Router::new().fallback(handle_request).with_state(route_table);
2153
2154 let listener = TcpListener::bind("127.0.0.1:0")
2155 .await
2156 .expect("mock-server: failed to bind port");
2157 let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
2158
2159 // Print the URL so the parent process can read it.
2160 println!("MOCK_SERVER_URL=http://{addr}");
2161 // Flush stdout explicitly so the parent does not block waiting.
2162 use std::io::Write;
2163 std::io::stdout().flush().expect("mock-server: failed to flush stdout");
2164
2165 // Spawn the server in the background.
2166 tokio::spawn(async move {
2167 axum::serve(listener, app).await.expect("mock-server: server error");
2168 });
2169
2170 // Block until stdin is closed — the parent process controls lifetime.
2171 let stdin = io::stdin();
2172 let mut lines = stdin.lock().lines();
2173 while lines.next().is_some() {}
2174}
2175"#
2176}
2177
2178#[allow(clippy::too_many_arguments)]
2183fn render_assertion(
2184 out: &mut String,
2185 assertion: &Assertion,
2186 result_var: &str,
2187 module: &str,
2188 dep_name: &str,
2189 is_error_context: bool,
2190 unwrapped_fields: &[(String, String)], field_resolver: &FieldResolver,
2192 result_is_tree: bool,
2193 result_is_simple: bool,
2194 result_is_vec: bool,
2195 result_is_option: bool,
2196) {
2197 let has_field = assertion.field.as_ref().is_some_and(|f| !f.is_empty());
2202 if result_is_vec && has_field && !is_error_context {
2203 let _ = writeln!(out, " for r in &{result_var} {{");
2204 render_assertion(
2205 out,
2206 assertion,
2207 "r",
2208 module,
2209 dep_name,
2210 is_error_context,
2211 unwrapped_fields,
2212 field_resolver,
2213 result_is_tree,
2214 result_is_simple,
2215 false, result_is_option,
2217 );
2218 let _ = writeln!(out, " }}");
2219 return;
2220 }
2221 if result_is_option && !is_error_context {
2224 let assertion_type = assertion.assertion_type.as_str();
2225 if !has_field && (assertion_type == "is_empty" || assertion_type == "not_empty") {
2226 let check = if assertion_type == "is_empty" {
2227 "is_none"
2228 } else {
2229 "is_some"
2230 };
2231 let _ = writeln!(
2232 out,
2233 " assert!({result_var}.{check}(), \"expected Option to be {check}\");"
2234 );
2235 return;
2236 }
2237 let _ = writeln!(
2241 out,
2242 " let r = {result_var}.as_ref().expect(\"Option<T> should be Some\");"
2243 );
2244 render_assertion(
2245 out,
2246 assertion,
2247 "r",
2248 module,
2249 dep_name,
2250 is_error_context,
2251 unwrapped_fields,
2252 field_resolver,
2253 result_is_tree,
2254 result_is_simple,
2255 result_is_vec,
2256 false, );
2258 return;
2259 }
2260 let _ = dep_name;
2261 if let Some(f) = &assertion.field {
2265 match f.as_str() {
2266 "chunks_have_content" => {
2267 match assertion.assertion_type.as_str() {
2268 "is_true" => {
2269 let _ = writeln!(
2270 out,
2271 " assert!({result_var}.chunks.as_ref().is_some_and(|chunks| !chunks.is_empty() && chunks.iter().all(|c| !c.content.is_empty())), \"expected all chunks to have content\");"
2272 );
2273 }
2274 "is_false" => {
2275 let _ = writeln!(
2276 out,
2277 " assert!({result_var}.chunks.as_ref().is_none() || {result_var}.chunks.as_ref().unwrap().iter().any(|c| c.content.is_empty()), \"expected some chunks to be empty\");"
2278 );
2279 }
2280 _ => {
2281 let _ = writeln!(
2282 out,
2283 " // unsupported assertion type on synthetic field chunks_have_content"
2284 );
2285 }
2286 }
2287 return;
2288 }
2289 "chunks_have_embeddings" => {
2290 match assertion.assertion_type.as_str() {
2291 "is_true" => {
2292 let _ = writeln!(
2293 out,
2294 " assert!({result_var}.chunks.as_ref().is_some_and(|c| c.iter().all(|ch| ch.embedding.as_ref().is_some_and(|e| !e.is_empty()))), \"expected all chunks to have embeddings\");"
2295 );
2296 }
2297 "is_false" => {
2298 let _ = writeln!(
2299 out,
2300 " assert!({result_var}.chunks.as_ref().is_none_or(|c| c.iter().any(|ch| ch.embedding.as_ref().is_none_or(|e| e.is_empty()))), \"expected some chunks to lack embeddings\");"
2301 );
2302 }
2303 _ => {
2304 let _ = writeln!(
2305 out,
2306 " // unsupported assertion type on synthetic field chunks_have_embeddings"
2307 );
2308 }
2309 }
2310 return;
2311 }
2312 "embeddings" => {
2316 let embed_list = result_var.to_string();
2319 match assertion.assertion_type.as_str() {
2320 "count_equals" => {
2321 if let Some(val) = &assertion.value {
2322 if let Some(n) = val.as_u64() {
2323 let _ = writeln!(
2324 out,
2325 " assert_eq!({embed_list}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {embed_list}.len());"
2326 );
2327 }
2328 }
2329 }
2330 "count_min" => {
2331 if let Some(val) = &assertion.value {
2332 if let Some(n) = val.as_u64() {
2333 if n <= 1 {
2334 let _ =
2335 writeln!(out, " assert!(!{embed_list}.is_empty(), \"expected >= {n}\");");
2336 } else {
2337 let _ = writeln!(
2338 out,
2339 " assert!({embed_list}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {embed_list}.len());"
2340 );
2341 }
2342 }
2343 }
2344 }
2345 "not_empty" => {
2346 let _ = writeln!(
2347 out,
2348 " assert!(!{embed_list}.is_empty(), \"expected non-empty embeddings\");"
2349 );
2350 }
2351 "is_empty" => {
2352 let _ = writeln!(
2353 out,
2354 " assert!({embed_list}.is_empty(), \"expected empty embeddings\");"
2355 );
2356 }
2357 _ => {
2358 let _ = writeln!(
2359 out,
2360 " // skipped: unsupported assertion type on synthetic field 'embeddings'"
2361 );
2362 }
2363 }
2364 return;
2365 }
2366 "embedding_dimensions" => {
2367 let embed_list = result_var;
2368 let expr = format!("{embed_list}.first().map_or(0, |e| e.len())");
2369 match assertion.assertion_type.as_str() {
2370 "equals" => {
2371 if let Some(val) = &assertion.value {
2372 let lit = numeric_literal(val);
2373 let _ = writeln!(
2374 out,
2375 " assert_eq!({expr}, {lit} as usize, \"equals assertion failed\");"
2376 );
2377 }
2378 }
2379 "greater_than" => {
2380 if let Some(val) = &assertion.value {
2381 let lit = numeric_literal(val);
2382 let _ = writeln!(out, " assert!({expr} > {lit} as usize, \"expected > {lit}\");");
2383 }
2384 }
2385 _ => {
2386 let _ = writeln!(
2387 out,
2388 " // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
2389 );
2390 }
2391 }
2392 return;
2393 }
2394 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
2395 let embed_list = result_var;
2396 let pred = match f.as_str() {
2397 "embeddings_valid" => {
2398 format!("{embed_list}.iter().all(|e| !e.is_empty())")
2399 }
2400 "embeddings_finite" => {
2401 format!("{embed_list}.iter().all(|e| e.iter().all(|v| v.is_finite()))")
2402 }
2403 "embeddings_non_zero" => {
2404 format!("{embed_list}.iter().all(|e| e.iter().any(|v| *v != 0.0_f32))")
2405 }
2406 "embeddings_normalized" => {
2407 format!(
2408 "{embed_list}.iter().all(|e| {{ let n: f64 = e.iter().map(|v| f64::from(*v) * f64::from(*v)).sum(); (n - 1.0_f64).abs() < 1e-3 }})"
2409 )
2410 }
2411 _ => unreachable!(),
2412 };
2413 match assertion.assertion_type.as_str() {
2414 "is_true" => {
2415 let _ = writeln!(out, " assert!({pred}, \"expected true\");");
2416 }
2417 "is_false" => {
2418 let _ = writeln!(out, " assert!(!({pred}), \"expected false\");");
2419 }
2420 _ => {
2421 let _ = writeln!(
2422 out,
2423 " // skipped: unsupported assertion type on synthetic field '{f}'"
2424 );
2425 }
2426 }
2427 return;
2428 }
2429 "keywords" => {
2433 let accessor = format!("{result_var}.extracted_keywords");
2434 match assertion.assertion_type.as_str() {
2435 "not_empty" => {
2436 let _ = writeln!(
2437 out,
2438 " assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected keywords to be present and non-empty\");"
2439 );
2440 }
2441 "is_empty" => {
2442 let _ = writeln!(
2443 out,
2444 " assert!({accessor}.as_ref().is_none_or(|v| v.is_empty()), \"expected keywords to be empty or absent\");"
2445 );
2446 }
2447 "count_min" => {
2448 if let Some(val) = &assertion.value {
2449 if let Some(n) = val.as_u64() {
2450 if n <= 1 {
2451 let _ = writeln!(
2452 out,
2453 " assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected >= {n}\");"
2454 );
2455 } else {
2456 let _ = writeln!(
2457 out,
2458 " assert!({accessor}.as_ref().is_some_and(|v| v.len() >= {n}), \"expected at least {n} keywords\");"
2459 );
2460 }
2461 }
2462 }
2463 }
2464 "count_equals" => {
2465 if let Some(val) = &assertion.value {
2466 if let Some(n) = val.as_u64() {
2467 let _ = writeln!(
2468 out,
2469 " assert!({accessor}.as_ref().is_some_and(|v| v.len() == {n}), \"expected exactly {n} keywords\");"
2470 );
2471 }
2472 }
2473 }
2474 _ => {
2475 let _ = writeln!(
2476 out,
2477 " // skipped: unsupported assertion type on synthetic field 'keywords'"
2478 );
2479 }
2480 }
2481 return;
2482 }
2483 "keywords_count" => {
2484 let expr = format!("{result_var}.extracted_keywords.as_ref().map_or(0, |v| v.len())");
2485 match assertion.assertion_type.as_str() {
2486 "equals" => {
2487 if let Some(val) = &assertion.value {
2488 let lit = numeric_literal(val);
2489 let _ = writeln!(
2490 out,
2491 " assert_eq!({expr}, {lit} as usize, \"equals assertion failed\");"
2492 );
2493 }
2494 }
2495 "less_than_or_equal" => {
2496 if let Some(val) = &assertion.value {
2497 let lit = numeric_literal(val);
2498 let _ = writeln!(out, " assert!({expr} <= {lit} as usize, \"expected <= {lit}\");");
2499 }
2500 }
2501 "greater_than_or_equal" => {
2502 if let Some(val) = &assertion.value {
2503 let lit = numeric_literal(val);
2504 let _ = writeln!(out, " assert!({expr} >= {lit} as usize, \"expected >= {lit}\");");
2505 }
2506 }
2507 "greater_than" => {
2508 if let Some(val) = &assertion.value {
2509 let lit = numeric_literal(val);
2510 let _ = writeln!(out, " assert!({expr} > {lit} as usize, \"expected > {lit}\");");
2511 }
2512 }
2513 "less_than" => {
2514 if let Some(val) = &assertion.value {
2515 let lit = numeric_literal(val);
2516 let _ = writeln!(out, " assert!({expr} < {lit} as usize, \"expected < {lit}\");");
2517 }
2518 }
2519 _ => {
2520 let _ = writeln!(
2521 out,
2522 " // skipped: unsupported assertion type on synthetic field 'keywords_count'"
2523 );
2524 }
2525 }
2526 return;
2527 }
2528 _ => {}
2529 }
2530 }
2531
2532 if let Some(f) = &assertion.field {
2534 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
2535 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
2536 return;
2537 }
2538 }
2539
2540 let field_access = match &assertion.field {
2548 Some(f) if !f.is_empty() => {
2549 if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
2550 local_var.clone()
2551 } else if result_is_simple {
2552 result_var.to_string()
2555 } else if f == result_var {
2556 result_var.to_string()
2559 } else if result_is_tree {
2560 tree_field_access_expr(f, result_var, module)
2563 } else {
2564 field_resolver.accessor(f, "rust", result_var)
2565 }
2566 }
2567 _ => result_var.to_string(),
2568 };
2569
2570 let is_unwrapped = assertion
2572 .field
2573 .as_ref()
2574 .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
2575
2576 match assertion.assertion_type.as_str() {
2577 "error" => {
2578 let _ = writeln!(out, " assert!({result_var}.is_err(), \"expected call to fail\");");
2579 if let Some(serde_json::Value::String(msg)) = &assertion.value {
2580 let escaped = escape_rust(msg);
2581 let _ = writeln!(
2582 out,
2583 " assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
2584 );
2585 }
2586 }
2587 "not_error" => {
2588 }
2590 "equals" => {
2591 if let Some(val) = &assertion.value {
2592 let expected = value_to_rust_string(val);
2593 if is_error_context {
2594 return;
2595 }
2596 if val.is_string() {
2599 let is_opt_str_not_unwrapped = assertion.field.as_ref().is_some_and(|f| {
2604 let resolved = field_resolver.resolve(f);
2605 let is_opt = field_resolver.is_optional(resolved);
2606 let is_arr = field_resolver.is_array(resolved);
2607 is_opt && !is_arr && !is_unwrapped
2608 });
2609 let field_expr = if is_opt_str_not_unwrapped {
2610 format!("{field_access}.as_deref().unwrap_or(\"\").trim()")
2611 } else {
2612 format!("{field_access}.trim()")
2613 };
2614 let _ = writeln!(
2615 out,
2616 " assert_eq!({field_expr}, {expected}, \"equals assertion failed\");"
2617 );
2618 } else if val.is_boolean() {
2619 if val.as_bool() == Some(true) {
2621 let _ = writeln!(out, " assert!({field_access}, \"equals assertion failed\");");
2622 } else {
2623 let _ = writeln!(out, " assert!(!{field_access}, \"equals assertion failed\");");
2624 }
2625 } else {
2626 let is_opt = assertion.field.as_ref().is_some_and(|f| {
2628 let resolved = field_resolver.resolve(f);
2629 field_resolver.is_optional(resolved)
2630 });
2631 if is_opt
2632 && !unwrapped_fields
2633 .iter()
2634 .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
2635 {
2636 let _ = writeln!(
2637 out,
2638 " assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
2639 );
2640 } else {
2641 let _ = writeln!(
2642 out,
2643 " assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
2644 );
2645 }
2646 }
2647 }
2648 }
2649 "contains" => {
2650 if let Some(val) = &assertion.value {
2651 let expected = value_to_rust_string(val);
2652 let line = format!(
2653 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
2654 );
2655 let _ = writeln!(out, "{line}");
2656 }
2657 }
2658 "contains_all" => {
2659 if let Some(values) = &assertion.values {
2660 for val in values {
2661 let expected = value_to_rust_string(val);
2662 let line = format!(
2663 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
2664 );
2665 let _ = writeln!(out, "{line}");
2666 }
2667 }
2668 }
2669 "not_contains" => {
2670 if let Some(val) = &assertion.value {
2671 let expected = value_to_rust_string(val);
2672 let line = format!(
2673 " assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
2674 );
2675 let _ = writeln!(out, "{line}");
2676 }
2677 }
2678 "not_empty" => {
2679 if let Some(f) = &assertion.field {
2680 let resolved = field_resolver.resolve(f);
2681 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2682 let is_arr = field_resolver.is_array(resolved);
2683 if is_opt && is_arr {
2684 let accessor = field_resolver.accessor(f, "rust", result_var);
2686 let _ = writeln!(
2687 out,
2688 " assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected {f} to be present and non-empty\");"
2689 );
2690 } else if is_opt {
2691 let accessor = field_resolver.accessor(f, "rust", result_var);
2693 let _ = writeln!(
2694 out,
2695 " assert!({accessor}.is_some(), \"expected {f} to be present\");"
2696 );
2697 } else {
2698 let _ = writeln!(
2699 out,
2700 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
2701 );
2702 }
2703 } else if result_is_option {
2704 let _ = writeln!(
2706 out,
2707 " assert!({field_access}.is_some(), \"expected non-empty value\");"
2708 );
2709 } else {
2710 let _ = writeln!(
2712 out,
2713 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
2714 );
2715 }
2716 }
2717 "is_empty" => {
2718 if let Some(f) = &assertion.field {
2719 let resolved = field_resolver.resolve(f);
2720 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2721 let is_arr = field_resolver.is_array(resolved);
2722 if is_opt && is_arr {
2723 let accessor = field_resolver.accessor(f, "rust", result_var);
2725 let _ = writeln!(
2726 out,
2727 " assert!({accessor}.as_ref().is_none_or(|v| v.is_empty()), \"expected {f} to be empty or absent\");"
2728 );
2729 } else if is_opt {
2730 let accessor = field_resolver.accessor(f, "rust", result_var);
2731 let _ = writeln!(out, " assert!({accessor}.is_none(), \"expected {f} to be absent\");");
2732 } else {
2733 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
2734 }
2735 } else {
2736 let _ = writeln!(out, " assert!({field_access}.is_none(), \"expected empty value\");");
2737 }
2738 }
2739 "contains_any" => {
2740 if let Some(values) = &assertion.values {
2741 let checks: Vec<String> = values
2742 .iter()
2743 .map(|v| {
2744 let expected = value_to_rust_string(v);
2745 format!("{field_access}.contains({expected})")
2746 })
2747 .collect();
2748 let joined = checks.join(" || ");
2749 let _ = writeln!(
2750 out,
2751 " assert!({joined}, \"expected to contain at least one of the specified values\");"
2752 );
2753 }
2754 }
2755 "greater_than" => {
2756 if let Some(val) = &assertion.value {
2757 if val.as_f64().is_some_and(|n| n < 0.0) {
2759 let _ = writeln!(
2760 out,
2761 " // skipped: greater_than with negative value is always true for unsigned types"
2762 );
2763 } else if val.as_u64() == Some(0) {
2764 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2766 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected > 0\");");
2767 } else {
2768 let lit = numeric_literal(val);
2769 let _ = writeln!(out, " assert!({field_access} > {lit}, \"expected > {lit}\");");
2770 }
2771 }
2772 }
2773 "less_than" => {
2774 if let Some(val) = &assertion.value {
2775 let lit = numeric_literal(val);
2776 let _ = writeln!(out, " assert!({field_access} < {lit}, \"expected < {lit}\");");
2777 }
2778 }
2779 "greater_than_or_equal" => {
2780 if let Some(val) = &assertion.value {
2781 let lit = numeric_literal(val);
2782 let is_opt_numeric = assertion.field.as_ref().is_some_and(|f| {
2785 let resolved = field_resolver.resolve(f);
2786 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2787 let is_arr = field_resolver.is_array(resolved);
2788 is_opt && !is_arr
2789 });
2790 if val.as_u64() == Some(1) && field_access.ends_with(".len()") {
2791 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2795 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= 1\");");
2796 } else if is_opt_numeric {
2797 let _ = writeln!(
2799 out,
2800 " assert!({field_access}.unwrap_or(0) >= {lit}, \"expected >= {lit}\");"
2801 );
2802 } else {
2803 let _ = writeln!(out, " assert!({field_access} >= {lit}, \"expected >= {lit}\");");
2804 }
2805 }
2806 }
2807 "less_than_or_equal" => {
2808 if let Some(val) = &assertion.value {
2809 let lit = numeric_literal(val);
2810 let _ = writeln!(out, " assert!({field_access} <= {lit}, \"expected <= {lit}\");");
2811 }
2812 }
2813 "starts_with" => {
2814 if let Some(val) = &assertion.value {
2815 let expected = value_to_rust_string(val);
2816 let _ = writeln!(
2817 out,
2818 " assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
2819 );
2820 }
2821 }
2822 "ends_with" => {
2823 if let Some(val) = &assertion.value {
2824 let expected = value_to_rust_string(val);
2825 let _ = writeln!(
2826 out,
2827 " assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
2828 );
2829 }
2830 }
2831 "min_length" => {
2832 if let Some(val) = &assertion.value {
2833 if let Some(n) = val.as_u64() {
2834 let _ = writeln!(
2835 out,
2836 " assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
2837 );
2838 }
2839 }
2840 }
2841 "max_length" => {
2842 if let Some(val) = &assertion.value {
2843 if let Some(n) = val.as_u64() {
2844 let _ = writeln!(
2845 out,
2846 " assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
2847 );
2848 }
2849 }
2850 }
2851 "count_min" => {
2852 if let Some(val) = &assertion.value {
2853 if let Some(n) = val.as_u64() {
2854 let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
2855 let resolved = field_resolver.resolve(f);
2856 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2857 let is_arr = field_resolver.is_array(resolved);
2858 is_opt && is_arr
2859 });
2860 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2861 if opt_arr_field {
2862 if n <= 1 {
2864 let _ = writeln!(
2865 out,
2866 " assert!({base}.as_ref().is_some_and(|v| !v.is_empty()), \"expected >= {n}\");"
2867 );
2868 } else {
2869 let _ = writeln!(
2870 out,
2871 " assert!({base}.as_ref().is_some_and(|v| v.len() >= {n}), \"expected at least {n} elements\");"
2872 );
2873 }
2874 } else if n <= 1 {
2875 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= {n}\");");
2876 } else {
2877 let _ = writeln!(
2878 out,
2879 " assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
2880 );
2881 }
2882 }
2883 }
2884 }
2885 "count_equals" => {
2886 if let Some(val) = &assertion.value {
2887 if let Some(n) = val.as_u64() {
2888 let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
2889 let resolved = field_resolver.resolve(f);
2890 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2891 let is_arr = field_resolver.is_array(resolved);
2892 is_opt && is_arr
2893 });
2894 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2895 if opt_arr_field {
2896 let _ = writeln!(
2897 out,
2898 " assert!({base}.as_ref().is_some_and(|v| v.len() == {n}), \"expected exactly {n} elements\");"
2899 );
2900 } else {
2901 let _ = writeln!(
2902 out,
2903 " assert_eq!({field_access}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {field_access}.len());"
2904 );
2905 }
2906 }
2907 }
2908 }
2909 "is_true" => {
2910 let _ = writeln!(out, " assert!({field_access}, \"expected true\");");
2911 }
2912 "is_false" => {
2913 let _ = writeln!(out, " assert!(!{field_access}, \"expected false\");");
2914 }
2915 "method_result" => {
2916 if let Some(method_name) = &assertion.method {
2917 let call_expr = if result_is_tree {
2921 build_tree_call_expr(field_access.as_str(), method_name, assertion.args.as_ref(), module)
2922 } else if let Some(args) = &assertion.args {
2923 let arg_lit = json_to_rust_literal(args, "");
2924 format!("{field_access}.{method_name}({arg_lit})")
2925 } else {
2926 format!("{field_access}.{method_name}()")
2927 };
2928
2929 let returns_numeric = result_is_tree && is_tree_numeric_method(method_name);
2932
2933 let check = assertion.check.as_deref().unwrap_or("is_true");
2934 match check {
2935 "equals" => {
2936 if let Some(val) = &assertion.value {
2937 if val.is_boolean() {
2938 if val.as_bool() == Some(true) {
2939 let _ = writeln!(
2940 out,
2941 " assert!({call_expr}, \"method_result equals assertion failed\");"
2942 );
2943 } else {
2944 let _ = writeln!(
2945 out,
2946 " assert!(!{call_expr}, \"method_result equals assertion failed\");"
2947 );
2948 }
2949 } else {
2950 let expected = value_to_rust_string(val);
2951 let _ = writeln!(
2952 out,
2953 " assert_eq!({call_expr}, {expected}, \"method_result equals assertion failed\");"
2954 );
2955 }
2956 }
2957 }
2958 "is_true" => {
2959 let _ = writeln!(
2960 out,
2961 " assert!({call_expr}, \"method_result is_true assertion failed\");"
2962 );
2963 }
2964 "is_false" => {
2965 let _ = writeln!(
2966 out,
2967 " assert!(!{call_expr}, \"method_result is_false assertion failed\");"
2968 );
2969 }
2970 "greater_than_or_equal" => {
2971 if let Some(val) = &assertion.value {
2972 let lit = numeric_literal(val);
2973 if returns_numeric {
2974 let _ = writeln!(out, " assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
2976 } else if val.as_u64() == Some(1) {
2977 let _ = writeln!(out, " assert!(!{call_expr}.is_empty(), \"expected >= 1\");");
2979 } else {
2980 let _ = writeln!(out, " assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
2981 }
2982 }
2983 }
2984 "count_min" => {
2985 if let Some(val) = &assertion.value {
2986 let n = val.as_u64().unwrap_or(0);
2987 if n <= 1 {
2988 let _ = writeln!(out, " assert!(!{call_expr}.is_empty(), \"expected >= {n}\");");
2989 } else {
2990 let _ = writeln!(
2991 out,
2992 " assert!({call_expr}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {call_expr}.len());"
2993 );
2994 }
2995 }
2996 }
2997 "is_error" => {
2998 let raw_call = call_expr.strip_suffix(".unwrap()").unwrap_or(&call_expr);
3000 let _ = writeln!(
3001 out,
3002 " assert!({raw_call}.is_err(), \"expected method to return error\");"
3003 );
3004 }
3005 "contains" => {
3006 if let Some(val) = &assertion.value {
3007 let expected = value_to_rust_string(val);
3008 let _ = writeln!(
3009 out,
3010 " assert!({call_expr}.contains({expected}), \"expected result to contain {{}}\", {expected});"
3011 );
3012 }
3013 }
3014 "not_empty" => {
3015 let _ = writeln!(
3016 out,
3017 " assert!(!{call_expr}.is_empty(), \"expected non-empty result\");"
3018 );
3019 }
3020 "is_empty" => {
3021 let _ = writeln!(out, " assert!({call_expr}.is_empty(), \"expected empty result\");");
3022 }
3023 other_check => {
3024 panic!("Rust e2e generator: unsupported method_result check type: {other_check}");
3025 }
3026 }
3027 } else {
3028 panic!("Rust e2e generator: method_result assertion missing 'method' field");
3029 }
3030 }
3031 other => {
3032 panic!("Rust e2e generator: unsupported assertion type: {other}");
3033 }
3034 }
3035}
3036
3037fn tree_field_access_expr(field: &str, result_var: &str, module: &str) -> String {
3045 match field {
3046 "root_child_count" => format!("{result_var}.root_node().child_count()"),
3047 "root_node_type" => format!("{result_var}.root_node().kind()"),
3048 "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
3049 "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{result_var})"),
3050 "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{result_var})"),
3051 "tree_to_sexp" => format!("{module}::tree_to_sexp(&{result_var})"),
3052 other => format!("{result_var}.{other}"),
3055 }
3056}
3057
3058fn build_tree_call_expr(
3065 field_access: &str,
3066 method_name: &str,
3067 args: Option<&serde_json::Value>,
3068 module: &str,
3069) -> String {
3070 match method_name {
3071 "root_child_count" => format!("{field_access}.root_node().child_count()"),
3072 "root_node_type" => format!("{field_access}.root_node().kind()"),
3073 "named_children_count" => format!("{field_access}.root_node().named_child_count()"),
3074 "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{field_access})"),
3075 "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{field_access})"),
3076 "tree_to_sexp" => format!("{module}::tree_to_sexp(&{field_access})"),
3077 "contains_node_type" => {
3078 let node_type = args
3079 .and_then(|a| a.get("node_type"))
3080 .and_then(|v| v.as_str())
3081 .unwrap_or("");
3082 format!("{module}::tree_contains_node_type(&{field_access}, \"{node_type}\")")
3083 }
3084 "find_nodes_by_type" => {
3085 let node_type = args
3086 .and_then(|a| a.get("node_type"))
3087 .and_then(|v| v.as_str())
3088 .unwrap_or("");
3089 format!("{module}::find_nodes_by_type(&{field_access}, \"{node_type}\")")
3090 }
3091 "run_query" => {
3092 let query_source = args
3093 .and_then(|a| a.get("query_source"))
3094 .and_then(|v| v.as_str())
3095 .unwrap_or("");
3096 let language = args
3097 .and_then(|a| a.get("language"))
3098 .and_then(|v| v.as_str())
3099 .unwrap_or("");
3100 format!(
3103 "{module}::run_query(&{field_access}, \"{language}\", r#\"{query_source}\"#, source.as_bytes()).unwrap()"
3104 )
3105 }
3106 _ => {
3108 if let Some(args) = args {
3109 let arg_lit = json_to_rust_literal(args, "");
3110 format!("{field_access}.{method_name}({arg_lit})")
3111 } else {
3112 format!("{field_access}.{method_name}()")
3113 }
3114 }
3115 }
3116}
3117
3118fn is_tree_numeric_method(method_name: &str) -> bool {
3122 matches!(
3123 method_name,
3124 "root_child_count" | "named_children_count" | "error_count" | "tree_error_count"
3125 )
3126}
3127
3128fn numeric_literal(value: &serde_json::Value) -> String {
3134 if let Some(n) = value.as_f64() {
3135 if n.fract() == 0.0 {
3136 return format!("{}", n as i64);
3139 }
3140 return format!("{n}_f64");
3141 }
3142 value.to_string()
3144}
3145
3146fn value_to_rust_string(value: &serde_json::Value) -> String {
3147 match value {
3148 serde_json::Value::String(s) => rust_raw_string(s),
3149 serde_json::Value::Bool(b) => format!("{b}"),
3150 serde_json::Value::Number(n) => n.to_string(),
3151 other => {
3152 let s = other.to_string();
3153 format!("\"{s}\"")
3154 }
3155 }
3156}
3157
3158fn resolve_visitor_trait(module: &str) -> String {
3164 if module.contains("html_to_markdown") {
3166 "HtmlVisitor".to_string()
3167 } else {
3168 "Visitor".to_string()
3170 }
3171}
3172
3173fn resolve_visitor_trait_module(module: &str) -> String {
3179 if module.contains("html_to_markdown") {
3180 format!("{module}::visitor")
3181 } else {
3182 module.to_string()
3183 }
3184}
3185
3186struct VisitorParam {
3195 name: &'static str,
3196 ty: &'static str,
3197 unwrap_option: bool,
3202}
3203
3204impl VisitorParam {
3205 const fn new(name: &'static str, ty: &'static str) -> Self {
3206 Self {
3207 name,
3208 ty,
3209 unwrap_option: false,
3210 }
3211 }
3212
3213 const fn option_str(name: &'static str) -> Self {
3218 Self {
3219 name,
3220 ty: "Option<&str>",
3221 unwrap_option: true,
3222 }
3223 }
3224
3225 const fn option_str_unused(name: &'static str) -> Self {
3228 Self {
3229 name,
3230 ty: "Option<&str>",
3231 unwrap_option: false,
3232 }
3233 }
3234}
3235
3236fn visitor_method_extra_params(method_name: &str) -> Vec<VisitorParam> {
3242 match method_name {
3243 "visit_link" => vec![
3244 VisitorParam::new("href", "&str"),
3245 VisitorParam::new("text", "&str"),
3246 VisitorParam::option_str_unused("_title"),
3247 ],
3248 "visit_image" => vec![
3249 VisitorParam::new("src", "&str"),
3250 VisitorParam::new("alt", "&str"),
3251 VisitorParam::option_str_unused("_title"),
3252 ],
3253 "visit_heading" => vec![
3254 VisitorParam::new("level", "u32"),
3255 VisitorParam::new("text", "&str"),
3256 VisitorParam::option_str_unused("_id"),
3257 ],
3258 "visit_code_block" => vec![
3259 VisitorParam::option_str_unused("_lang"),
3260 VisitorParam::new("code", "&str"),
3261 ],
3262 "visit_code_inline"
3263 | "visit_strong"
3264 | "visit_emphasis"
3265 | "visit_strikethrough"
3266 | "visit_underline"
3267 | "visit_subscript"
3268 | "visit_superscript"
3269 | "visit_mark"
3270 | "visit_button"
3271 | "visit_summary"
3272 | "visit_figcaption"
3273 | "visit_definition_term"
3274 | "visit_definition_description"
3275 | "visit_text" => {
3276 vec![VisitorParam::new("text", "&str")]
3277 }
3278 "visit_list_item" => vec![
3279 VisitorParam::new("ordered", "bool"),
3280 VisitorParam::new("marker", "&str"),
3281 VisitorParam::new("text", "&str"),
3282 ],
3283 "visit_blockquote" => vec![
3284 VisitorParam::new("content", "&str"),
3285 VisitorParam::new("depth", "usize"),
3286 ],
3287 "visit_table_row" => vec![
3288 VisitorParam::new("cells", "&[String]"),
3289 VisitorParam::new("is_header", "bool"),
3290 ],
3291 "visit_custom_element" => vec![VisitorParam::new("tag_name", "&str"), VisitorParam::new("html", "&str")],
3292 "visit_form" => vec![
3293 VisitorParam::option_str_unused("_action"),
3294 VisitorParam::option_str_unused("_method"),
3295 ],
3296 "visit_input" => vec![
3297 VisitorParam::new("input_type", "&str"),
3298 VisitorParam::option_str_unused("_name"),
3299 VisitorParam::option_str_unused("_value"),
3300 ],
3301 "visit_audio" | "visit_video" | "visit_iframe" => {
3302 vec![VisitorParam::option_str("src")]
3303 }
3304 "visit_details" => vec![VisitorParam::new("open", "bool")],
3305 "visit_list_start" => vec![VisitorParam::new("ordered", "bool")],
3306 "visit_list_end" => vec![
3307 VisitorParam::new("ordered", "bool"),
3308 VisitorParam::new("output", "&str"),
3309 ],
3310 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
3311 vec![VisitorParam::new("output", "&str")]
3312 }
3313 _ => vec![],
3314 }
3315}
3316
3317fn emit_rust_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
3325 let extra = visitor_method_extra_params(method_name);
3326
3327 let referenced: Vec<&str> = if let CallbackAction::CustomTemplate { template } = action {
3329 extra
3330 .iter()
3331 .filter(|p| {
3332 let bare = p.name.trim_start_matches('_');
3333 template.contains(&format!("{{{bare}}}"))
3334 })
3335 .map(|p| p.name.trim_start_matches('_'))
3336 .collect()
3337 } else {
3338 Vec::new()
3339 };
3340
3341 let mut param_parts = vec!["_: &NodeContext".to_string()];
3343 for p in &extra {
3344 let bare = p.name.trim_start_matches('_');
3345 let is_used = referenced.contains(&bare);
3346 let param_name = if is_used {
3347 bare.to_string()
3348 } else if p.name.starts_with('_') {
3349 p.name.to_string()
3350 } else {
3351 format!("_{bare}")
3352 };
3353 param_parts.push(format!("{param_name}: {}", p.ty));
3354 }
3355 let params = param_parts.join(", ");
3356
3357 let _ = writeln!(out, " fn {method_name}(&mut self, {params}) -> VisitResult {{");
3358
3359 if let CallbackAction::CustomTemplate { .. } = action {
3362 for p in &extra {
3363 let bare = p.name.trim_start_matches('_');
3364 if p.unwrap_option && referenced.contains(&bare) {
3365 let _ = writeln!(out, " let {bare} = {bare}.unwrap_or_default();");
3366 }
3367 }
3368 }
3369
3370 match action {
3371 CallbackAction::Skip => {
3372 let _ = writeln!(out, " VisitResult::Skip");
3373 }
3374 CallbackAction::Continue => {
3375 let _ = writeln!(out, " VisitResult::Continue");
3376 }
3377 CallbackAction::PreserveHtml => {
3378 let _ = writeln!(out, " VisitResult::PreserveHtml");
3379 }
3380 CallbackAction::Custom { output } => {
3381 let escaped = escape_rust(output);
3382 let _ = writeln!(out, " VisitResult::Custom(\"{escaped}\".to_string())");
3383 }
3384 CallbackAction::CustomTemplate { template } => {
3385 let escaped = escape_rust(template);
3386 let _ = writeln!(out, " VisitResult::Custom(format!(\"{escaped}\"))");
3387 }
3388 }
3389 let _ = writeln!(out, " }}");
3390}