1use crate::codegen::resolve_field;
7use crate::config::E2eConfig;
8use crate::escape::{escape_rust, rust_raw_string, sanitize_filename, sanitize_ident};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, CallbackAction, CorsConfig, Fixture, FixtureGroup, StaticFilesConfig};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use alef_core::hash::{self, CommentStyle};
14use alef_core::template_versions as tv;
15use anyhow::Result;
16use std::fmt::Write as FmtWrite;
17use std::path::PathBuf;
18
19pub struct RustE2eCodegen;
21
22impl super::E2eCodegen for RustE2eCodegen {
23 fn generate(
24 &self,
25 groups: &[FixtureGroup],
26 e2e_config: &E2eConfig,
27 alef_config: &AlefConfig,
28 ) -> Result<Vec<GeneratedFile>> {
29 let mut files = Vec::new();
30 let output_base = PathBuf::from(e2e_config.effective_output()).join("rust");
31
32 let crate_name = resolve_crate_name(e2e_config, alef_config);
34 let crate_path = resolve_crate_path(e2e_config, &crate_name);
35 let dep_name = crate_name.replace('-', "_");
36
37 let all_call_configs = std::iter::once(&e2e_config.call).chain(e2e_config.calls.values());
40 let needs_serde_json = all_call_configs
41 .flat_map(|c| c.args.iter())
42 .any(|a| a.arg_type == "json_object" || a.arg_type == "handle");
43
44 let needs_mock_server = groups
47 .iter()
48 .flat_map(|g| g.fixtures.iter())
49 .any(|f| !is_skipped(f, "rust") && f.mock_response.is_some());
50
51 let needs_http_tests = groups
53 .iter()
54 .flat_map(|g| g.fixtures.iter())
55 .any(|f| !is_skipped(f, "rust") && f.http.is_some());
56
57 let needs_tower_http = groups
59 .iter()
60 .flat_map(|g| g.fixtures.iter())
61 .filter(|f| !is_skipped(f, "rust"))
62 .filter_map(|f| f.http.as_ref())
63 .filter_map(|h| h.handler.middleware.as_ref())
64 .any(|m| m.cors.is_some() || m.static_files.is_some());
65
66 let any_async_call = std::iter::once(&e2e_config.call)
68 .chain(e2e_config.calls.values())
69 .any(|c| c.r#async);
70 let needs_tokio = needs_mock_server || needs_http_tests || any_async_call;
71
72 let crate_version = resolve_crate_version(e2e_config);
73 files.push(GeneratedFile {
74 path: output_base.join("Cargo.toml"),
75 content: render_cargo_toml(
76 &crate_name,
77 &dep_name,
78 &crate_path,
79 needs_serde_json,
80 needs_mock_server,
81 needs_http_tests,
82 needs_tokio,
83 needs_tower_http,
84 e2e_config.dep_mode,
85 crate_version.as_deref(),
86 &alef_config.crate_config.features,
87 ),
88 generated_header: true,
89 });
90
91 if needs_mock_server {
93 files.push(GeneratedFile {
94 path: output_base.join("tests").join("mock_server.rs"),
95 content: render_mock_server_module(),
96 generated_header: true,
97 });
98 }
99 if needs_mock_server || needs_http_tests {
102 files.push(GeneratedFile {
103 path: output_base.join("src").join("main.rs"),
104 content: render_mock_server_binary(),
105 generated_header: true,
106 });
107 }
108
109 for group in groups {
111 let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
112
113 if fixtures.is_empty() {
114 continue;
115 }
116
117 let filename = format!("{}_test.rs", sanitize_filename(&group.category));
118 let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name, needs_mock_server);
119
120 files.push(GeneratedFile {
121 path: output_base.join("tests").join(filename),
122 content,
123 generated_header: true,
124 });
125 }
126
127 Ok(files)
128 }
129
130 fn language_name(&self) -> &'static str {
131 "rust"
132 }
133}
134
135fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
140 alef_config.crate_config.name.clone()
144}
145
146fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
147 e2e_config
148 .resolve_package("rust")
149 .and_then(|p| p.path.clone())
150 .unwrap_or_else(|| format!("../../crates/{crate_name}"))
151}
152
153fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
154 e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
155}
156
157fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
158 call_config
159 .overrides
160 .get("rust")
161 .and_then(|o| o.function.clone())
162 .unwrap_or_else(|| call_config.function.clone())
163}
164
165fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
166 resolve_module_for_call(&e2e_config.call, dep_name)
167}
168
169fn resolve_module_for_call(call_config: &crate::config::CallConfig, dep_name: &str) -> String {
170 let overrides = call_config.overrides.get("rust");
173 overrides
174 .and_then(|o| o.crate_name.clone())
175 .or_else(|| overrides.and_then(|o| o.module.clone()))
176 .unwrap_or_else(|| dep_name.to_string())
177}
178
179fn is_skipped(fixture: &Fixture, language: &str) -> bool {
180 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
181}
182
183#[allow(clippy::too_many_arguments)]
188pub fn render_cargo_toml(
189 crate_name: &str,
190 dep_name: &str,
191 crate_path: &str,
192 needs_serde_json: bool,
193 needs_mock_server: bool,
194 needs_http_tests: bool,
195 needs_tokio: bool,
196 needs_tower_http: bool,
197 dep_mode: crate::config::DependencyMode,
198 version: Option<&str>,
199 features: &[String],
200) -> String {
201 let e2e_name = format!("{dep_name}-e2e-rust");
202 let effective_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
206 let features_str = if effective_features.is_empty() {
207 String::new()
208 } else {
209 format!(", default-features = false, features = {:?}", effective_features)
210 };
211 let dep_spec = match dep_mode {
212 crate::config::DependencyMode::Registry => {
213 let ver = version.unwrap_or("0.1.0");
214 if crate_name != dep_name {
215 format!("{dep_name} = {{ package = \"{crate_name}\", version = \"{ver}\"{features_str} }}")
216 } else if effective_features.is_empty() {
217 format!("{dep_name} = \"{ver}\"")
218 } else {
219 format!("{dep_name} = {{ version = \"{ver}\"{features_str} }}")
220 }
221 }
222 crate::config::DependencyMode::Local => {
223 if crate_name != dep_name {
224 format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\"{features_str} }}")
225 } else if effective_features.is_empty() {
226 format!("{dep_name} = {{ path = \"{crate_path}\" }}")
227 } else {
228 format!("{dep_name} = {{ path = \"{crate_path}\"{features_str} }}")
229 }
230 }
231 };
232 let effective_needs_serde_json = needs_serde_json || needs_mock_server || needs_http_tests;
236 let serde_line = if effective_needs_serde_json {
237 "\nserde_json = \"1\""
238 } else {
239 ""
240 };
241 let needs_axum = needs_mock_server || needs_http_tests;
250 let mock_lines = if needs_axum {
251 let mut lines = format!(
252 "\naxum = \"{axum}\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\nwalkdir = \"{walkdir}\"",
253 axum = tv::cargo::AXUM,
254 walkdir = tv::cargo::WALKDIR,
255 );
256 if needs_mock_server {
257 lines.push_str(&format!(
258 "\ntokio-stream = \"{tokio_stream}\"",
259 tokio_stream = tv::cargo::TOKIO_STREAM
260 ));
261 }
262 if needs_http_tests {
263 lines.push_str("\naxum-test = \"20\"\nbytes = \"1\"");
264 }
265 if needs_tower_http {
266 lines.push_str(&format!(
267 "\ntower-http = {{ version = \"{tower_http}\", features = [\"cors\", \"fs\"] }}\ntempfile = \"{tempfile}\"",
268 tower_http = tv::cargo::TOWER_HTTP,
269 tempfile = tv::cargo::TEMPFILE,
270 ));
271 }
272 lines
273 } else {
274 String::new()
275 };
276 let mut machete_ignored: Vec<&str> = Vec::new();
277 if effective_needs_serde_json {
278 machete_ignored.push("\"serde_json\"");
279 }
280 if needs_axum {
281 machete_ignored.push("\"axum\"");
282 machete_ignored.push("\"serde\"");
283 machete_ignored.push("\"walkdir\"");
284 }
285 if needs_mock_server {
286 machete_ignored.push("\"tokio-stream\"");
287 }
288 if needs_http_tests {
289 machete_ignored.push("\"axum-test\"");
290 machete_ignored.push("\"bytes\"");
291 }
292 if needs_tower_http {
293 machete_ignored.push("\"tower-http\"");
294 machete_ignored.push("\"tempfile\"");
295 }
296 let machete_section = if machete_ignored.is_empty() {
297 String::new()
298 } else {
299 format!(
300 "\n[package.metadata.cargo-machete]\nignored = [{}]\n",
301 machete_ignored.join(", ")
302 )
303 };
304 let tokio_line = if needs_tokio {
305 "\ntokio = { version = \"1\", features = [\"full\"] }"
306 } else {
307 ""
308 };
309 let bin_section = if needs_mock_server || needs_http_tests {
310 "\n[[bin]]\nname = \"mock-server\"\npath = \"src/main.rs\"\n"
311 } else {
312 ""
313 };
314 let header = hash::header(CommentStyle::Hash);
315 format!(
316 r#"{header}
317[workspace]
318
319[package]
320name = "{e2e_name}"
321version = "0.1.0"
322edition = "2021"
323license = "MIT"
324publish = false
325{bin_section}
326[dependencies]
327{dep_spec}{serde_line}{mock_lines}{tokio_line}
328{machete_section}"#
329 )
330}
331
332fn render_test_file(
333 category: &str,
334 fixtures: &[&Fixture],
335 e2e_config: &E2eConfig,
336 dep_name: &str,
337 needs_mock_server: bool,
338) -> String {
339 let mut out = String::new();
340 out.push_str(&hash::header(CommentStyle::DoubleSlash));
341 let _ = writeln!(out, "//! E2e tests for category: {category}");
342 let _ = writeln!(out);
343
344 let module = resolve_module(e2e_config, dep_name);
345 let field_resolver = FieldResolver::new(
346 &e2e_config.fields,
347 &e2e_config.fields_optional,
348 &e2e_config.result_fields,
349 &e2e_config.fields_array,
350 );
351
352 let file_has_http = fixtures.iter().any(|f| f.http.is_some());
354 let is_call_based = |f: &Fixture| -> bool {
359 if f.http.is_some() {
360 return false;
361 }
362 let cc = e2e_config.resolve_call(f.call.as_deref());
363 !resolve_function_name_for_call(cc).is_empty()
364 };
365 let file_has_call_based = fixtures.iter().any(|f| is_call_based(f));
366
367 if file_has_call_based {
370 let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
371 for fixture in fixtures.iter().filter(|f| is_call_based(f)) {
372 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
373 let fn_name = resolve_function_name_for_call(call_config);
374 let mod_name = resolve_module_for_call(call_config, dep_name);
375 imported.insert((mod_name, fn_name));
376 }
377 let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
379 for (mod_name, fn_name) in &imported {
380 by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
381 }
382 for (mod_name, fns) in &by_module {
383 if fns.len() == 1 {
384 let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
385 } else {
386 let joined = fns.join(", ");
387 let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
388 }
389 }
390 }
391
392 if file_has_http {
394 let _ = writeln!(out, "use {module}::{{App, RequestContext}};");
395 }
396
397 let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
399 if has_handle_args {
400 let _ = writeln!(out, "use {module}::CrawlConfig;");
401 }
402 for arg in &e2e_config.call.args {
403 if arg.arg_type == "handle" {
404 use heck::ToSnakeCase;
405 let constructor_name = format!("create_{}", arg.name.to_snake_case());
406 let _ = writeln!(out, "use {module}::{constructor_name};");
407 }
408 }
409
410 let file_needs_mock = needs_mock_server && fixtures.iter().any(|f| f.mock_response.is_some());
412 if file_needs_mock {
413 let _ = writeln!(out, "mod mock_server;");
414 let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
415 }
416
417 let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
421 if file_needs_visitor {
422 let visitor_trait = resolve_visitor_trait(&module);
423 let _ = writeln!(out, "use {module}::{{{visitor_trait}, NodeContext, VisitResult}};");
424 }
425
426 let _ = writeln!(out);
427
428 for fixture in fixtures {
429 render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
430 let _ = writeln!(out);
431 }
432
433 if !out.ends_with('\n') {
434 out.push('\n');
435 }
436 out
437}
438
439fn render_test_function(
440 out: &mut String,
441 fixture: &Fixture,
442 e2e_config: &E2eConfig,
443 dep_name: &str,
444 field_resolver: &FieldResolver,
445) {
446 if fixture.http.is_some() {
448 render_http_test_function(out, fixture, dep_name);
449 return;
450 }
451
452 let fn_name = sanitize_ident(&fixture.id);
453 let description = &fixture.description;
454 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
455 let function_name = resolve_function_name_for_call(call_config);
456 let module = resolve_module_for_call(call_config, dep_name);
457
458 if fixture.http.is_none() && fixture.mock_response.is_none() && function_name.is_empty() {
462 let _ = writeln!(out, "#[tokio::test]");
463 let _ = writeln!(out, "async fn test_{fn_name}() {{");
464 let _ = writeln!(out, " // {description}");
465 let _ = writeln!(
466 out,
467 " // TODO: implement when a callable API is available for this fixture type."
468 );
469 let _ = writeln!(out, "}}");
470 return;
471 }
472 let result_var = &call_config.result_var;
473 let has_mock = fixture.mock_response.is_some();
474
475 let is_async = call_config.r#async || has_mock;
477 if is_async {
478 let _ = writeln!(out, "#[tokio::test]");
479 let _ = writeln!(out, "async fn test_{fn_name}() {{");
480 } else {
481 let _ = writeln!(out, "#[test]");
482 let _ = writeln!(out, "fn test_{fn_name}() {{");
483 }
484 let _ = writeln!(out, " // {description}");
485
486 if has_mock {
489 render_mock_server_setup(out, fixture, e2e_config);
490 }
491
492 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
494
495 let rust_overrides = call_config.overrides.get("rust");
497 let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
498 let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
499
500 let mut arg_exprs: Vec<String> = Vec::new();
502 for arg in &call_config.args {
503 let value = resolve_field(&fixture.input, &arg.field);
504 let var_name = &arg.name;
505 let (bindings, expr) = render_rust_arg(
506 var_name,
507 value,
508 &arg.arg_type,
509 arg.optional,
510 &module,
511 &fixture.id,
512 if has_mock {
513 Some("mock_server.url.as_str()")
514 } else {
515 None
516 },
517 arg.owned,
518 arg.element_type.as_deref(),
519 );
520 for binding in &bindings {
521 let _ = writeln!(out, " {binding}");
522 }
523 let final_expr = if wrap_options_in_some && arg.arg_type == "json_object" {
527 if let Some(rest) = expr.strip_prefix('&') {
528 format!("Some({rest}.clone())")
529 } else {
530 format!("Some({expr})")
531 }
532 } else {
533 expr
534 };
535 arg_exprs.push(final_expr);
536 }
537
538 if let Some(visitor_spec) = &fixture.visitor {
540 let _ = writeln!(out, " struct _TestVisitor;");
541 let _ = writeln!(out, " impl {} for _TestVisitor {{", resolve_visitor_trait(&module));
542 for (method_name, action) in &visitor_spec.callbacks {
543 emit_rust_visitor_method(out, method_name, action);
544 }
545 let _ = writeln!(out, " }}");
546 let _ = writeln!(
547 out,
548 " let visitor = std::rc::Rc::new(std::cell::RefCell::new(_TestVisitor));"
549 );
550 arg_exprs.push("Some(visitor)".to_string());
551 } else {
552 arg_exprs.extend(extra_args);
555 }
556
557 let args_str = arg_exprs.join(", ");
558
559 let await_suffix = if is_async { ".await" } else { "" };
560
561 let result_is_tree = call_config.result_var == "tree";
562 let result_is_simple = rust_overrides.is_some_and(|o| o.result_is_simple);
565 let result_is_vec = rust_overrides.is_some_and(|o| o.result_is_vec);
568 let result_is_option = rust_overrides.is_some_and(|o| o.result_is_option);
571
572 if has_error_assertion {
573 let _ = writeln!(out, " let {result_var} = {function_name}({args_str}){await_suffix};");
574 for assertion in &fixture.assertions {
576 render_assertion(
577 out,
578 assertion,
579 result_var,
580 &module,
581 dep_name,
582 true,
583 &[],
584 field_resolver,
585 result_is_tree,
586 result_is_simple,
587 false,
588 false,
589 );
590 }
591 let _ = writeln!(out, "}}");
592 return;
593 }
594
595 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
597
598 let has_usable_assertion = fixture.assertions.iter().any(|a| {
602 if a.assertion_type == "not_error" || a.assertion_type == "error" {
603 return false;
604 }
605 if a.assertion_type == "method_result" {
606 let supported_checks = [
609 "equals",
610 "is_true",
611 "is_false",
612 "greater_than_or_equal",
613 "count_min",
614 "is_error",
615 "contains",
616 "not_empty",
617 "is_empty",
618 ];
619 let check = a.check.as_deref().unwrap_or("is_true");
620 if a.method.is_none() || !supported_checks.contains(&check) {
621 return false;
622 }
623 }
624 match &a.field {
625 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
626 _ => true,
627 }
628 });
629
630 let result_binding = if has_usable_assertion {
631 result_var.to_string()
632 } else {
633 "_".to_string()
634 };
635
636 let has_field_access = fixture
640 .assertions
641 .iter()
642 .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
643 let only_emptiness_checks = !has_field_access
644 && fixture.assertions.iter().all(|a| {
645 matches!(
646 a.assertion_type.as_str(),
647 "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
648 )
649 });
650
651 let returns_result = rust_overrides
654 .and_then(|o| o.returns_result)
655 .unwrap_or(call_config.returns_result);
656
657 let unwrap_suffix = if returns_result {
658 ".expect(\"should succeed\")"
659 } else {
660 ""
661 };
662 if only_emptiness_checks || !returns_result {
663 let _ = writeln!(
665 out,
666 " let {result_binding} = {function_name}({args_str}){await_suffix};"
667 );
668 } else if has_not_error || !fixture.assertions.is_empty() {
669 let _ = writeln!(
670 out,
671 " let {result_binding} = {function_name}({args_str}){await_suffix}{unwrap_suffix};"
672 );
673 } else {
674 let _ = writeln!(
675 out,
676 " let {result_binding} = {function_name}({args_str}){await_suffix};"
677 );
678 }
679
680 let string_assertion_types = [
686 "equals",
687 "contains",
688 "contains_all",
689 "contains_any",
690 "not_contains",
691 "starts_with",
692 "ends_with",
693 "min_length",
694 "max_length",
695 "matches_regex",
696 ];
697 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); if !result_is_vec {
699 for assertion in &fixture.assertions {
700 if let Some(f) = &assertion.field {
701 if !f.is_empty()
702 && string_assertion_types.contains(&assertion.assertion_type.as_str())
703 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
704 {
705 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
708 if !is_string_assertion {
709 continue;
710 }
711 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
712 let _ = writeln!(out, " {binding}");
713 unwrapped_fields.push((f.clone(), local_var));
714 }
715 }
716 }
717 }
718 }
719
720 for assertion in &fixture.assertions {
722 if assertion.assertion_type == "not_error" {
723 continue;
725 }
726 render_assertion(
727 out,
728 assertion,
729 result_var,
730 &module,
731 dep_name,
732 false,
733 &unwrapped_fields,
734 field_resolver,
735 result_is_tree,
736 result_is_simple,
737 result_is_vec,
738 result_is_option,
739 );
740 }
741
742 let _ = writeln!(out, "}}");
743}
744
745#[allow(clippy::too_many_arguments)]
750fn render_rust_arg(
751 name: &str,
752 value: &serde_json::Value,
753 arg_type: &str,
754 optional: bool,
755 module: &str,
756 fixture_id: &str,
757 mock_base_url: Option<&str>,
758 owned: bool,
759 element_type: Option<&str>,
760) -> (Vec<String>, String) {
761 if arg_type == "mock_url" {
762 let lines = vec![format!(
763 "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
764 )];
765 return (lines, format!("&{name}"));
766 }
767 if arg_type == "base_url" {
769 if let Some(url_expr) = mock_base_url {
770 return (vec![], url_expr.to_string());
771 }
772 }
774 if arg_type == "handle" {
775 use heck::ToSnakeCase;
779 let constructor_name = format!("create_{}", name.to_snake_case());
780 let mut lines = Vec::new();
781 if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
782 lines.push(format!(
783 "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
784 ));
785 } else {
786 let json_literal = serde_json::to_string(value).unwrap_or_default();
788 let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
789 lines.push(format!(
790 "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
791 ));
792 lines.push(format!(
793 "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
794 ));
795 }
796 return (lines, format!("&{name}"));
797 }
798 if arg_type == "json_object" {
799 return render_json_object_arg(name, value, optional, owned, element_type, module);
800 }
801 if arg_type == "bytes" && !optional {
806 if let Some(raw) = value.as_str() {
807 if matches!(classify_bytes_value(raw), BytesKind::FilePath) {
808 let lines = vec![format!(
809 "let {name} = std::fs::read(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../../test_documents/\", \"{raw}\")).expect(\"fixture file should exist\");"
810 )];
811 let pass = if owned { name.to_string() } else { format!("&{name}") };
812 return (lines, pass);
813 }
814 }
815 }
816 if arg_type == "bytes" && optional {
817 if let Some(raw) = value.as_str() {
818 if matches!(classify_bytes_value(raw), BytesKind::FilePath) {
819 let lines = vec![format!(
820 "let {name} = Some(std::fs::read(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../../test_documents/\", \"{raw}\")).expect(\"fixture file should exist\"));"
821 )];
822 let pass = if owned {
823 name.to_string()
824 } else {
825 format!("{name}.as_deref().map(|v| v.as_slice())")
826 };
827 return (lines, pass);
828 }
829 }
830 }
831 if value.is_null() && !optional {
832 let default_val = match arg_type {
834 "string" => "String::new()".to_string(),
835 "int" | "integer" => "0".to_string(),
836 "float" | "number" => "0.0_f64".to_string(),
837 "bool" | "boolean" => "false".to_string(),
838 _ => "Default::default()".to_string(),
839 };
840 let expr = if arg_type == "string" {
842 format!("&{name}")
843 } else {
844 name.to_string()
845 };
846 return (vec![format!("let {name} = {default_val};")], expr);
847 }
848 let literal = json_to_rust_literal(value, arg_type);
849 let pass_by_ref = arg_type == "bytes";
854 let optional_expr = |n: &str| {
855 if arg_type == "string" {
856 if owned {
857 n.to_string()
858 } else {
859 format!("{n}.as_deref()")
860 }
861 } else if arg_type == "bytes" {
862 if owned {
863 n.to_string()
864 } else {
865 format!("{n}.as_deref().map(|v| v.as_slice())")
866 }
867 } else {
868 n.to_string()
872 }
873 };
874 let expr = |n: &str| {
875 if arg_type == "bytes" {
876 if owned {
877 format!("{n}.as_bytes().to_vec()")
882 } else {
883 format!("{n}.as_bytes()")
884 }
885 } else if arg_type == "string" {
886 if owned {
887 format!("{n}.to_string()")
888 } else {
889 n.to_string()
890 }
891 } else if pass_by_ref {
892 format!("&{n}")
893 } else {
894 n.to_string()
895 }
896 };
897 if optional && value.is_null() {
898 let none_decl = match arg_type {
899 "string" if owned => format!("let {name}: Option<String> = None;"),
900 "string" => format!("let {name}: Option<String> = None;"),
901 "bytes" if owned => format!("let {name}: Option<Vec<u8>> = None;"),
902 "bytes" => format!("let {name}: Option<Vec<u8>> = None;"),
903 _ => format!("let {name} = None;"),
904 };
905 (vec![none_decl], optional_expr(name))
906 } else if optional {
907 if arg_type == "bytes" && owned {
909 (
910 vec![format!("let {name} = Some(({literal}).as_bytes().to_vec());")],
911 optional_expr(name),
912 )
913 } else if arg_type == "string" && owned {
914 (
915 vec![format!("let {name} = Some(({literal}).to_string());")],
916 optional_expr(name),
917 )
918 } else {
919 (vec![format!("let {name} = Some({literal});")], optional_expr(name))
920 }
921 } else {
922 (vec![format!("let {name} = {literal};")], expr(name))
923 }
924}
925
926#[derive(Debug, PartialEq, Eq)]
931enum BytesKind {
932 FilePath,
934 InlineText,
936 Base64,
939}
940
941fn classify_bytes_value(s: &str) -> BytesKind {
950 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
951 return BytesKind::InlineText;
952 }
953 let first = s.chars().next().unwrap_or('\0');
954 if first.is_ascii_alphanumeric() || first == '_' {
955 if let Some(slash_pos) = s.find('/') {
956 if slash_pos > 0 {
957 let after_slash = &s[slash_pos + 1..];
958 if after_slash.contains('.') && !after_slash.is_empty() {
959 return BytesKind::FilePath;
960 }
961 }
962 }
963 }
964 BytesKind::Base64
965}
966
967fn render_json_object_arg(
975 name: &str,
976 value: &serde_json::Value,
977 optional: bool,
978 owned: bool,
979 element_type: Option<&str>,
980 _module: &str,
981) -> (Vec<String>, String) {
982 let pass_by_ref = !owned;
984
985 if value.is_null() && optional {
986 let expr = if pass_by_ref {
988 format!("&{name}")
989 } else {
990 name.to_string()
991 };
992 return (vec![format!("let {name} = Default::default();")], expr);
993 }
994
995 let normalized = super::normalize_json_keys_to_snake_case(value);
998 let json_literal = json_value_to_macro_literal(&normalized);
1000 let mut lines = Vec::new();
1001 lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
1002
1003 let deser_expr = if let Some(elem) = element_type {
1006 format!("serde_json::from_value::<Vec<{elem}>>({name}_json).unwrap()")
1007 } else {
1008 format!("serde_json::from_value({name}_json).unwrap()")
1009 };
1010
1011 lines.push(format!("let {name} = {deser_expr};"));
1014 let expr = if pass_by_ref {
1015 format!("&{name}")
1016 } else {
1017 name.to_string()
1018 };
1019 (lines, expr)
1020}
1021
1022fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
1024 match value {
1025 serde_json::Value::Null => "null".to_string(),
1026 serde_json::Value::Bool(b) => format!("{b}"),
1027 serde_json::Value::Number(n) => n.to_string(),
1028 serde_json::Value::String(s) => {
1029 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
1030 format!("\"{escaped}\"")
1031 }
1032 serde_json::Value::Array(arr) => {
1033 let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
1034 format!("[{}]", items.join(", "))
1035 }
1036 serde_json::Value::Object(obj) => {
1037 let entries: Vec<String> = obj
1038 .iter()
1039 .map(|(k, v)| {
1040 let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
1041 format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
1042 })
1043 .collect();
1044 format!("{{{}}}", entries.join(", "))
1045 }
1046 }
1047}
1048
1049fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
1050 match value {
1051 serde_json::Value::Null => "None".to_string(),
1052 serde_json::Value::Bool(b) => format!("{b}"),
1053 serde_json::Value::Number(n) => {
1054 if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
1055 if let Some(f) = n.as_f64() {
1056 return format!("{f}_f64");
1057 }
1058 }
1059 n.to_string()
1060 }
1061 serde_json::Value::String(s) => rust_raw_string(s),
1062 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
1063 let json_str = serde_json::to_string(value).unwrap_or_default();
1064 let literal = rust_raw_string(&json_str);
1065 format!("serde_json::from_str({literal}).unwrap()")
1066 }
1067 }
1068}
1069
1070enum ServerCall<'a> {
1076 Shorthand(&'a str),
1078 AxumMethod(&'a str),
1080}
1081
1082enum RouteRegistration<'a> {
1084 Shorthand(&'a str),
1086 Explicit(&'a str),
1088}
1089
1090fn render_http_test_function(out: &mut String, fixture: &Fixture, dep_name: &str) {
1096 let http = match &fixture.http {
1097 Some(h) => h,
1098 None => return,
1099 };
1100
1101 let fn_name = sanitize_ident(&fixture.id);
1102 let description = &fixture.description;
1103
1104 let route = &http.handler.route;
1105
1106 let route_reg = match http.handler.method.to_lowercase().as_str() {
1109 "get" => RouteRegistration::Shorthand("get"),
1110 "post" => RouteRegistration::Shorthand("post"),
1111 "put" => RouteRegistration::Shorthand("put"),
1112 "patch" => RouteRegistration::Shorthand("patch"),
1113 "delete" => RouteRegistration::Shorthand("delete"),
1114 "head" => RouteRegistration::Explicit("Head"),
1115 "options" => RouteRegistration::Explicit("Options"),
1116 "trace" => RouteRegistration::Explicit("Trace"),
1117 _ => RouteRegistration::Shorthand("get"),
1118 };
1119
1120 let server_call = match http.request.method.to_uppercase().as_str() {
1123 "GET" => ServerCall::Shorthand("get"),
1124 "POST" => ServerCall::Shorthand("post"),
1125 "PUT" => ServerCall::Shorthand("put"),
1126 "PATCH" => ServerCall::Shorthand("patch"),
1127 "DELETE" => ServerCall::Shorthand("delete"),
1128 "HEAD" => ServerCall::AxumMethod("HEAD"),
1129 "OPTIONS" => ServerCall::AxumMethod("OPTIONS"),
1130 "TRACE" => ServerCall::AxumMethod("TRACE"),
1131 _ => ServerCall::Shorthand("get"),
1132 };
1133
1134 let req_path = &http.request.path;
1135 let status = http.expected_response.status_code;
1136
1137 let body_str = match &http.expected_response.body {
1139 Some(b) => serde_json::to_string(b).unwrap_or_else(|_| "{}".to_string()),
1140 None => String::new(),
1141 };
1142 let body_literal = rust_raw_string(&body_str);
1143
1144 let req_body_str = match &http.request.body {
1146 Some(b) => serde_json::to_string(b).unwrap_or_else(|_| "{}".to_string()),
1147 None => String::new(),
1148 };
1149 let has_req_body = !req_body_str.is_empty();
1150
1151 let middleware = http.handler.middleware.as_ref();
1153 let cors_cfg: Option<&CorsConfig> = middleware.and_then(|m| m.cors.as_ref());
1154 let static_files_cfgs: Option<&Vec<StaticFilesConfig>> = middleware.and_then(|m| m.static_files.as_ref());
1155 let has_static_files = static_files_cfgs.is_some_and(|v| !v.is_empty());
1156
1157 let _ = writeln!(out, "#[tokio::test]");
1158 let _ = writeln!(out, "async fn test_{fn_name}() {{");
1159 let _ = writeln!(out, " // {description}");
1160
1161 if has_static_files {
1163 render_static_files_test(out, fixture, static_files_cfgs.unwrap(), &server_call, req_path, status);
1164 return;
1165 }
1166
1167 let _ = writeln!(out, " let expected_body = {body_literal}.to_string();");
1169 let _ = writeln!(out, " let mut app = {dep_name}::App::new();");
1170
1171 match &route_reg {
1173 RouteRegistration::Shorthand(method) => {
1174 let _ = writeln!(
1175 out,
1176 " app.route({dep_name}::{method}({route:?}), move |_ctx: {dep_name}::RequestContext| {{"
1177 );
1178 }
1179 RouteRegistration::Explicit(variant) => {
1180 let _ = writeln!(
1181 out,
1182 " app.route({dep_name}::RouteBuilder::new({dep_name}::Method::{variant}, {route:?}), move |_ctx: {dep_name}::RequestContext| {{"
1183 );
1184 }
1185 }
1186 let _ = writeln!(out, " let body = expected_body.clone();");
1187 let _ = writeln!(out, " async move {{");
1188 let _ = writeln!(out, " Ok(axum::http::Response::builder()");
1189 let _ = writeln!(out, " .status({status}u16)");
1190 let _ = writeln!(out, " .header(\"content-type\", \"application/json\")");
1191 let _ = writeln!(out, " .body(axum::body::Body::from(body))");
1192 let _ = writeln!(out, " .unwrap())");
1193 let _ = writeln!(out, " }}");
1194 let _ = writeln!(out, " }}).unwrap();");
1195
1196 let _ = writeln!(out, " let router = app.into_router().unwrap();");
1198 if let Some(cors) = cors_cfg {
1199 render_cors_layer(out, cors);
1200 }
1201 let _ = writeln!(out, " let server = axum_test::TestServer::new(router);");
1202
1203 match &server_call {
1205 ServerCall::Shorthand(method) => {
1206 let _ = writeln!(out, " let response = server.{method}({req_path:?})");
1207 }
1208 ServerCall::AxumMethod(method) => {
1209 let _ = writeln!(
1210 out,
1211 " let response = server.method(axum::http::Method::{method}, {req_path:?})"
1212 );
1213 }
1214 }
1215
1216 for (name, value) in &http.request.headers {
1218 let n = rust_raw_string(name);
1219 let v = rust_raw_string(value);
1220 let _ = writeln!(out, " .add_header({n}, {v})");
1221 }
1222
1223 if has_req_body {
1225 let req_body_literal = rust_raw_string(&req_body_str);
1226 let _ = writeln!(
1227 out,
1228 " .bytes(bytes::Bytes::copy_from_slice({req_body_literal}.as_bytes()))"
1229 );
1230 }
1231
1232 let _ = writeln!(out, " .await;");
1233
1234 if cors_cfg.is_some() && (200..300).contains(&status) {
1238 let _ = writeln!(
1239 out,
1240 " assert!(response.status_code().is_success(), \"expected CORS success status, got {{}}\", response.status_code());"
1241 );
1242 } else {
1243 let _ = writeln!(out, " assert_eq!(response.status_code().as_u16(), {status}u16);");
1244 }
1245
1246 let _ = writeln!(out, "}}");
1247}
1248
1249fn render_cors_layer(out: &mut String, cors: &CorsConfig) {
1254 let _ = writeln!(
1255 out,
1256 " // Apply CorsLayer from tower-http based on fixture CORS config."
1257 );
1258 let _ = writeln!(out, " use tower_http::cors::CorsLayer;");
1259 let _ = writeln!(out, " use axum::http::{{HeaderName, HeaderValue, Method}};");
1260 let _ = writeln!(out, " let cors_layer = CorsLayer::new()");
1261
1262 if cors.allow_origins.is_empty() {
1264 let _ = writeln!(out, " .allow_origin(tower_http::cors::Any)");
1265 } else {
1266 let _ = writeln!(out, " .allow_origin([");
1267 for origin in &cors.allow_origins {
1268 let _ = writeln!(out, " \"{origin}\".parse::<HeaderValue>().unwrap(),");
1269 }
1270 let _ = writeln!(out, " ])");
1271 }
1272
1273 if cors.allow_methods.is_empty() {
1275 let _ = writeln!(out, " .allow_methods(tower_http::cors::Any)");
1276 } else {
1277 let methods: Vec<String> = cors
1278 .allow_methods
1279 .iter()
1280 .map(|m| format!("Method::{}", m.to_uppercase()))
1281 .collect();
1282 let _ = writeln!(out, " .allow_methods([{}])", methods.join(", "));
1283 }
1284
1285 if cors.allow_headers.is_empty() {
1287 let _ = writeln!(out, " .allow_headers(tower_http::cors::Any)");
1288 } else {
1289 let headers: Vec<String> = cors
1290 .allow_headers
1291 .iter()
1292 .map(|h| {
1293 let lower = h.to_lowercase();
1294 match lower.as_str() {
1295 "content-type" => "axum::http::header::CONTENT_TYPE".to_string(),
1296 "authorization" => "axum::http::header::AUTHORIZATION".to_string(),
1297 "accept" => "axum::http::header::ACCEPT".to_string(),
1298 _ => format!("HeaderName::from_static(\"{lower}\")"),
1299 }
1300 })
1301 .collect();
1302 let _ = writeln!(out, " .allow_headers([{}])", headers.join(", "));
1303 }
1304
1305 if let Some(secs) = cors.max_age {
1307 let _ = writeln!(out, " .max_age(std::time::Duration::from_secs({secs}));");
1308 } else {
1309 let _ = writeln!(out, " ;");
1310 }
1311
1312 let _ = writeln!(out, " let router = router.layer(cors_layer);");
1313}
1314
1315fn render_static_files_test(
1320 out: &mut String,
1321 fixture: &Fixture,
1322 cfgs: &[StaticFilesConfig],
1323 server_call: &ServerCall<'_>,
1324 req_path: &str,
1325 status: u16,
1326) {
1327 let http = fixture.http.as_ref().unwrap();
1328
1329 let _ = writeln!(out, " use tower_http::services::ServeDir;");
1330 let _ = writeln!(out, " use axum::Router;");
1331 let _ = writeln!(out, " let tmp_dir = tempfile::tempdir().expect(\"tmp dir\");");
1332
1333 let _ = writeln!(out, " let mut router = Router::new();");
1335 for cfg in cfgs {
1336 for file in &cfg.files {
1337 let file_path = file.path.replace('\\', "/");
1338 let content = rust_raw_string(&file.content);
1339 if file_path.contains('/') {
1340 let parent: String = file_path.rsplitn(2, '/').last().unwrap_or("").to_string();
1341 let _ = writeln!(
1342 out,
1343 " std::fs::create_dir_all(tmp_dir.path().join(\"{parent}\")).unwrap();"
1344 );
1345 }
1346 let _ = writeln!(
1347 out,
1348 " std::fs::write(tmp_dir.path().join(\"{file_path}\"), {content}).unwrap();"
1349 );
1350 }
1351 let prefix = &cfg.route_prefix;
1352 let serve_dir_expr = if cfg.index_file {
1353 "ServeDir::new(tmp_dir.path()).append_index_html_on_directories(true)".to_string()
1354 } else {
1355 "ServeDir::new(tmp_dir.path())".to_string()
1356 };
1357 let _ = writeln!(out, " router = router.nest_service({prefix:?}, {serve_dir_expr});");
1358 }
1359
1360 let _ = writeln!(out, " let server = axum_test::TestServer::new(router);");
1361
1362 match server_call {
1364 ServerCall::Shorthand(method) => {
1365 let _ = writeln!(out, " let response = server.{method}({req_path:?})");
1366 }
1367 ServerCall::AxumMethod(method) => {
1368 let _ = writeln!(
1369 out,
1370 " let response = server.method(axum::http::Method::{method}, {req_path:?})"
1371 );
1372 }
1373 }
1374
1375 for (name, value) in &http.request.headers {
1377 let n = rust_raw_string(name);
1378 let v = rust_raw_string(value);
1379 let _ = writeln!(out, " .add_header({n}, {v})");
1380 }
1381
1382 let _ = writeln!(out, " .await;");
1383 let _ = writeln!(out, " assert_eq!(response.status_code().as_u16(), {status}u16);");
1384 let _ = writeln!(out, "}}");
1385}
1386
1387fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
1397 let mock = match fixture.mock_response.as_ref() {
1398 Some(m) => m,
1399 None => return,
1400 };
1401
1402 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
1404 let path = call_config.path.as_deref().unwrap_or("/");
1405 let method = call_config.method.as_deref().unwrap_or("POST");
1406
1407 let status = mock.status;
1408
1409 let mut header_entries: Vec<(&String, &String)> = mock.headers.iter().collect();
1411 header_entries.sort_by(|a, b| a.0.cmp(b.0));
1412 let render_headers = |out: &mut String| {
1413 let _ = writeln!(out, " headers: vec![");
1414 for (name, value) in &header_entries {
1415 let n = rust_raw_string(name);
1416 let v = rust_raw_string(value);
1417 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
1418 }
1419 let _ = writeln!(out, " ],");
1420 };
1421
1422 if let Some(chunks) = &mock.stream_chunks {
1423 let _ = writeln!(out, " let mock_route = MockRoute {{");
1425 let _ = writeln!(out, " path: \"{path}\",");
1426 let _ = writeln!(out, " method: \"{method}\",");
1427 let _ = writeln!(out, " status: {status},");
1428 let _ = writeln!(out, " body: String::new(),");
1429 let _ = writeln!(out, " stream_chunks: vec![");
1430 for chunk in chunks {
1431 let chunk_str = match chunk {
1432 serde_json::Value::String(s) => rust_raw_string(s),
1433 other => {
1434 let s = serde_json::to_string(other).unwrap_or_default();
1435 rust_raw_string(&s)
1436 }
1437 };
1438 let _ = writeln!(out, " {chunk_str}.to_string(),");
1439 }
1440 let _ = writeln!(out, " ],");
1441 render_headers(out);
1442 let _ = writeln!(out, " }};");
1443 } else {
1444 let body_str = match &mock.body {
1446 Some(b) => {
1447 let s = serde_json::to_string(b).unwrap_or_default();
1448 rust_raw_string(&s)
1449 }
1450 None => rust_raw_string("{}"),
1451 };
1452 let _ = writeln!(out, " let mock_route = MockRoute {{");
1453 let _ = writeln!(out, " path: \"{path}\",");
1454 let _ = writeln!(out, " method: \"{method}\",");
1455 let _ = writeln!(out, " status: {status},");
1456 let _ = writeln!(out, " body: {body_str}.to_string(),");
1457 let _ = writeln!(out, " stream_chunks: vec![],");
1458 render_headers(out);
1459 let _ = writeln!(out, " }};");
1460 }
1461
1462 let _ = writeln!(out, " let mock_server = MockServer::start(vec![mock_route]).await;");
1463}
1464
1465pub fn render_mock_server_module() -> String {
1467 hash::header(CommentStyle::DoubleSlash)
1470 + r#"//
1471// Minimal axum-based mock HTTP server for e2e tests.
1472
1473use std::net::SocketAddr;
1474use std::sync::Arc;
1475
1476use axum::Router;
1477use axum::body::Body;
1478use axum::extract::State;
1479use axum::http::{Request, StatusCode};
1480use axum::response::{IntoResponse, Response};
1481use tokio::net::TcpListener;
1482
1483/// A single mock route: match by path + method, return a configured response.
1484#[derive(Clone, Debug)]
1485pub struct MockRoute {
1486 /// URL path to match, e.g. `"/v1/chat/completions"`.
1487 pub path: &'static str,
1488 /// HTTP method to match, e.g. `"POST"` or `"GET"`.
1489 pub method: &'static str,
1490 /// HTTP status code to return.
1491 pub status: u16,
1492 /// Response body JSON string (used when `stream_chunks` is empty).
1493 pub body: String,
1494 /// Ordered SSE data payloads for streaming responses.
1495 /// Each entry becomes `data: <chunk>\n\n` in the response.
1496 /// A final `data: [DONE]\n\n` is always appended.
1497 pub stream_chunks: Vec<String>,
1498 /// Response headers to apply (name, value) pairs.
1499 /// Multiple entries with the same name produce multiple header lines.
1500 pub headers: Vec<(String, String)>,
1501}
1502
1503struct ServerState {
1504 routes: Vec<MockRoute>,
1505}
1506
1507pub struct MockServer {
1508 /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
1509 pub url: String,
1510 handle: tokio::task::JoinHandle<()>,
1511}
1512
1513impl MockServer {
1514 /// Start a mock server with the given routes. Binds to a random port on
1515 /// localhost and returns immediately once the server is listening.
1516 pub async fn start(routes: Vec<MockRoute>) -> Self {
1517 let state = Arc::new(ServerState { routes });
1518
1519 let app = Router::new().fallback(handle_request).with_state(state);
1520
1521 let listener = TcpListener::bind("127.0.0.1:0")
1522 .await
1523 .expect("Failed to bind mock server port");
1524 let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
1525 let url = format!("http://{addr}");
1526
1527 let handle = tokio::spawn(async move {
1528 axum::serve(listener, app).await.expect("Mock server failed");
1529 });
1530
1531 MockServer { url, handle }
1532 }
1533
1534 /// Stop the mock server.
1535 pub fn shutdown(self) {
1536 self.handle.abort();
1537 }
1538}
1539
1540impl Drop for MockServer {
1541 fn drop(&mut self) {
1542 self.handle.abort();
1543 }
1544}
1545
1546async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
1547 let path = req.uri().path().to_owned();
1548 let method = req.method().as_str().to_uppercase();
1549
1550 for route in &state.routes {
1551 if route.path == path && route.method.to_uppercase() == method {
1552 let status =
1553 StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1554
1555 if !route.stream_chunks.is_empty() {
1556 // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
1557 let mut sse = String::new();
1558 for chunk in &route.stream_chunks {
1559 sse.push_str("data: ");
1560 sse.push_str(chunk);
1561 sse.push_str("\n\n");
1562 }
1563 sse.push_str("data: [DONE]\n\n");
1564
1565 let mut builder = Response::builder()
1566 .status(status)
1567 .header("content-type", "text/event-stream")
1568 .header("cache-control", "no-cache");
1569 for (name, value) in &route.headers {
1570 builder = builder.header(name, value);
1571 }
1572 return builder.body(Body::from(sse)).unwrap().into_response();
1573 }
1574
1575 let mut builder =
1576 Response::builder().status(status).header("content-type", "application/json");
1577 for (name, value) in &route.headers {
1578 builder = builder.header(name, value);
1579 }
1580 return builder.body(Body::from(route.body.clone())).unwrap().into_response();
1581 }
1582 }
1583
1584 // No matching route → 404.
1585 Response::builder()
1586 .status(StatusCode::NOT_FOUND)
1587 .body(Body::from(format!("No mock route for {method} {path}")))
1588 .unwrap()
1589 .into_response()
1590}
1591"#
1592}
1593
1594pub fn render_mock_server_binary() -> String {
1606 hash::header(CommentStyle::DoubleSlash)
1607 + r#"//
1608// Standalone mock HTTP server binary for cross-language e2e tests.
1609// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
1610//
1611// Usage: mock-server [fixtures-dir]
1612// fixtures-dir defaults to "../../fixtures"
1613//
1614// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
1615// then blocks until stdin is closed (parent process exit triggers cleanup).
1616
1617use std::collections::HashMap;
1618use std::io::{self, BufRead};
1619use std::net::SocketAddr;
1620use std::path::Path;
1621use std::sync::Arc;
1622
1623use axum::Router;
1624use axum::body::Body;
1625use axum::extract::State;
1626use axum::http::{Request, StatusCode};
1627use axum::response::{IntoResponse, Response};
1628use serde::Deserialize;
1629use tokio::net::TcpListener;
1630
1631// ---------------------------------------------------------------------------
1632// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
1633// Supports both schemas:
1634// liter-llm: mock_response: { status, body, stream_chunks }
1635// spikard: http.expected_response: { status_code, body, headers }
1636// ---------------------------------------------------------------------------
1637
1638#[derive(Debug, Deserialize)]
1639struct MockResponse {
1640 status: u16,
1641 #[serde(default)]
1642 body: Option<serde_json::Value>,
1643 #[serde(default)]
1644 stream_chunks: Option<Vec<serde_json::Value>>,
1645 #[serde(default)]
1646 headers: HashMap<String, String>,
1647}
1648
1649#[derive(Debug, Deserialize)]
1650struct HttpExpectedResponse {
1651 status_code: u16,
1652 #[serde(default)]
1653 body: Option<serde_json::Value>,
1654 #[serde(default)]
1655 headers: HashMap<String, String>,
1656}
1657
1658#[derive(Debug, Deserialize)]
1659struct HttpFixture {
1660 expected_response: HttpExpectedResponse,
1661}
1662
1663#[derive(Debug, Deserialize)]
1664struct Fixture {
1665 id: String,
1666 #[serde(default)]
1667 mock_response: Option<MockResponse>,
1668 #[serde(default)]
1669 http: Option<HttpFixture>,
1670}
1671
1672impl Fixture {
1673 /// Bridge both schemas into a unified MockResponse.
1674 fn as_mock_response(&self) -> Option<MockResponse> {
1675 if let Some(mock) = &self.mock_response {
1676 return Some(MockResponse {
1677 status: mock.status,
1678 body: mock.body.clone(),
1679 stream_chunks: mock.stream_chunks.clone(),
1680 headers: mock.headers.clone(),
1681 });
1682 }
1683 if let Some(http) = &self.http {
1684 return Some(MockResponse {
1685 status: http.expected_response.status_code,
1686 body: http.expected_response.body.clone(),
1687 stream_chunks: None,
1688 headers: http.expected_response.headers.clone(),
1689 });
1690 }
1691 None
1692 }
1693}
1694
1695// ---------------------------------------------------------------------------
1696// Route table
1697// ---------------------------------------------------------------------------
1698
1699#[derive(Clone, Debug)]
1700struct MockRoute {
1701 status: u16,
1702 body: String,
1703 stream_chunks: Vec<String>,
1704 headers: Vec<(String, String)>,
1705}
1706
1707type RouteTable = Arc<HashMap<String, MockRoute>>;
1708
1709// ---------------------------------------------------------------------------
1710// Axum handler
1711// ---------------------------------------------------------------------------
1712
1713async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
1714 let path = req.uri().path().to_owned();
1715
1716 // Try exact match first
1717 if let Some(route) = routes.get(&path) {
1718 return serve_route(route);
1719 }
1720
1721 // Try prefix match: find a route that is a prefix of the request path
1722 // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
1723 for (route_path, route) in routes.iter() {
1724 if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
1725 return serve_route(route);
1726 }
1727 }
1728
1729 Response::builder()
1730 .status(StatusCode::NOT_FOUND)
1731 .body(Body::from(format!("No mock route for {path}")))
1732 .unwrap()
1733 .into_response()
1734}
1735
1736fn serve_route(route: &MockRoute) -> Response {
1737 let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1738
1739 if !route.stream_chunks.is_empty() {
1740 let mut sse = String::new();
1741 for chunk in &route.stream_chunks {
1742 sse.push_str("data: ");
1743 sse.push_str(chunk);
1744 sse.push_str("\n\n");
1745 }
1746 sse.push_str("data: [DONE]\n\n");
1747
1748 let mut builder = Response::builder()
1749 .status(status)
1750 .header("content-type", "text/event-stream")
1751 .header("cache-control", "no-cache");
1752 for (name, value) in &route.headers {
1753 builder = builder.header(name, value);
1754 }
1755 return builder.body(Body::from(sse)).unwrap().into_response();
1756 }
1757
1758 // Only set the default content-type if the fixture does not override it.
1759 // Use application/json when the body looks like JSON (starts with { or [),
1760 // otherwise fall back to text/plain to avoid clients failing JSON-decode.
1761 let has_content_type = route.headers.iter().any(|(k, _)| k.to_lowercase() == "content-type");
1762 let mut builder = Response::builder().status(status);
1763 if !has_content_type {
1764 let trimmed = route.body.trim_start();
1765 let default_ct = if trimmed.starts_with('{') || trimmed.starts_with('[') {
1766 "application/json"
1767 } else {
1768 "text/plain"
1769 };
1770 builder = builder.header("content-type", default_ct);
1771 }
1772 for (name, value) in &route.headers {
1773 // Skip content-encoding headers — the mock server returns uncompressed bodies.
1774 // Sending a content-encoding without actually encoding the body would cause
1775 // clients to fail decompression.
1776 if name.to_lowercase() == "content-encoding" {
1777 continue;
1778 }
1779 // The <<absent>> sentinel means this header must NOT be present in the
1780 // real server response — do not emit it from the mock server either.
1781 if value == "<<absent>>" {
1782 continue;
1783 }
1784 // Replace the <<uuid>> sentinel with a real UUID v4 so clients can
1785 // assert the header value matches the UUID pattern.
1786 if value == "<<uuid>>" {
1787 let uuid = format!(
1788 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
1789 rand_u32(),
1790 rand_u16(),
1791 rand_u16() & 0x0fff,
1792 (rand_u16() & 0x3fff) | 0x8000,
1793 rand_u48(),
1794 );
1795 builder = builder.header(name, uuid);
1796 continue;
1797 }
1798 builder = builder.header(name, value);
1799 }
1800 builder.body(Body::from(route.body.clone())).unwrap().into_response()
1801}
1802
1803/// Generate a pseudo-random u32 using the current time nanoseconds.
1804fn rand_u32() -> u32 {
1805 use std::time::{SystemTime, UNIX_EPOCH};
1806 let ns = SystemTime::now()
1807 .duration_since(UNIX_EPOCH)
1808 .map(|d| d.subsec_nanos())
1809 .unwrap_or(0);
1810 ns ^ (ns.wrapping_shl(13)) ^ (ns.wrapping_shr(17))
1811}
1812
1813fn rand_u16() -> u16 {
1814 (rand_u32() & 0xffff) as u16
1815}
1816
1817fn rand_u48() -> u64 {
1818 ((rand_u32() as u64) << 16) | (rand_u16() as u64)
1819}
1820
1821// ---------------------------------------------------------------------------
1822// Fixture loading
1823// ---------------------------------------------------------------------------
1824
1825fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
1826 let mut routes = HashMap::new();
1827 load_routes_recursive(fixtures_dir, &mut routes);
1828 routes
1829}
1830
1831fn load_routes_recursive(dir: &Path, routes: &mut HashMap<String, MockRoute>) {
1832 let entries = match std::fs::read_dir(dir) {
1833 Ok(e) => e,
1834 Err(err) => {
1835 eprintln!("warning: cannot read directory {}: {err}", dir.display());
1836 return;
1837 }
1838 };
1839
1840 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
1841 paths.sort();
1842
1843 for path in paths {
1844 if path.is_dir() {
1845 load_routes_recursive(&path, routes);
1846 } else if path.extension().is_some_and(|ext| ext == "json") {
1847 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1848 if filename == "schema.json" || filename.starts_with('_') {
1849 continue;
1850 }
1851 let content = match std::fs::read_to_string(&path) {
1852 Ok(c) => c,
1853 Err(err) => {
1854 eprintln!("warning: cannot read {}: {err}", path.display());
1855 continue;
1856 }
1857 };
1858 let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
1859 match serde_json::from_str(&content) {
1860 Ok(v) => v,
1861 Err(err) => {
1862 eprintln!("warning: cannot parse {}: {err}", path.display());
1863 continue;
1864 }
1865 }
1866 } else {
1867 match serde_json::from_str::<Fixture>(&content) {
1868 Ok(f) => vec![f],
1869 Err(err) => {
1870 eprintln!("warning: cannot parse {}: {err}", path.display());
1871 continue;
1872 }
1873 }
1874 };
1875
1876 for fixture in fixtures {
1877 if let Some(mock) = fixture.as_mock_response() {
1878 let route_path = format!("/fixtures/{}", fixture.id);
1879 let body = mock
1880 .body
1881 .as_ref()
1882 .map(|b| match b {
1883 // Plain strings (e.g. text/plain bodies) are stored as JSON strings in
1884 // fixtures. Return the raw value so clients receive the string itself,
1885 // not its JSON-encoded form with extra surrounding quotes.
1886 serde_json::Value::String(s) => s.clone(),
1887 other => serde_json::to_string(other).unwrap_or_default(),
1888 })
1889 .unwrap_or_default();
1890 let stream_chunks = mock
1891 .stream_chunks
1892 .unwrap_or_default()
1893 .into_iter()
1894 .map(|c| match c {
1895 serde_json::Value::String(s) => s,
1896 other => serde_json::to_string(&other).unwrap_or_default(),
1897 })
1898 .collect();
1899 let mut headers: Vec<(String, String)> =
1900 mock.headers.into_iter().collect();
1901 headers.sort_by(|a, b| a.0.cmp(&b.0));
1902 routes.insert(route_path, MockRoute { status: mock.status, body, stream_chunks, headers });
1903 }
1904 }
1905 }
1906 }
1907}
1908
1909// ---------------------------------------------------------------------------
1910// Entry point
1911// ---------------------------------------------------------------------------
1912
1913#[tokio::main]
1914async fn main() {
1915 let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
1916 let fixtures_dir = Path::new(&fixtures_dir_arg);
1917
1918 let routes = load_routes(fixtures_dir);
1919 eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
1920
1921 let route_table: RouteTable = Arc::new(routes);
1922 let app = Router::new().fallback(handle_request).with_state(route_table);
1923
1924 let listener = TcpListener::bind("127.0.0.1:0")
1925 .await
1926 .expect("mock-server: failed to bind port");
1927 let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
1928
1929 // Print the URL so the parent process can read it.
1930 println!("MOCK_SERVER_URL=http://{addr}");
1931 // Flush stdout explicitly so the parent does not block waiting.
1932 use std::io::Write;
1933 std::io::stdout().flush().expect("mock-server: failed to flush stdout");
1934
1935 // Spawn the server in the background.
1936 tokio::spawn(async move {
1937 axum::serve(listener, app).await.expect("mock-server: server error");
1938 });
1939
1940 // Block until stdin is closed — the parent process controls lifetime.
1941 let stdin = io::stdin();
1942 let mut lines = stdin.lock().lines();
1943 while lines.next().is_some() {}
1944}
1945"#
1946}
1947
1948#[allow(clippy::too_many_arguments)]
1953fn render_assertion(
1954 out: &mut String,
1955 assertion: &Assertion,
1956 result_var: &str,
1957 module: &str,
1958 dep_name: &str,
1959 is_error_context: bool,
1960 unwrapped_fields: &[(String, String)], field_resolver: &FieldResolver,
1962 result_is_tree: bool,
1963 result_is_simple: bool,
1964 result_is_vec: bool,
1965 result_is_option: bool,
1966) {
1967 let has_field = assertion.field.as_ref().is_some_and(|f| !f.is_empty());
1972 if result_is_vec && has_field && !is_error_context {
1973 let _ = writeln!(out, " for r in &{result_var} {{");
1974 render_assertion(
1975 out,
1976 assertion,
1977 "r",
1978 module,
1979 dep_name,
1980 is_error_context,
1981 unwrapped_fields,
1982 field_resolver,
1983 result_is_tree,
1984 result_is_simple,
1985 false, result_is_option,
1987 );
1988 let _ = writeln!(out, " }}");
1989 return;
1990 }
1991 if result_is_option && !is_error_context {
1994 let assertion_type = assertion.assertion_type.as_str();
1995 if !has_field && (assertion_type == "is_empty" || assertion_type == "not_empty") {
1996 let check = if assertion_type == "is_empty" {
1997 "is_none"
1998 } else {
1999 "is_some"
2000 };
2001 let _ = writeln!(
2002 out,
2003 " assert!({result_var}.{check}(), \"expected Option to be {check}\");"
2004 );
2005 return;
2006 }
2007 let _ = writeln!(
2011 out,
2012 " let r = {result_var}.as_ref().expect(\"Option<T> should be Some\");"
2013 );
2014 render_assertion(
2015 out,
2016 assertion,
2017 "r",
2018 module,
2019 dep_name,
2020 is_error_context,
2021 unwrapped_fields,
2022 field_resolver,
2023 result_is_tree,
2024 result_is_simple,
2025 result_is_vec,
2026 false, );
2028 return;
2029 }
2030 let _ = dep_name;
2031 if let Some(f) = &assertion.field {
2035 match f.as_str() {
2036 "chunks_have_content" => {
2037 match assertion.assertion_type.as_str() {
2038 "is_true" => {
2039 let _ = writeln!(
2040 out,
2041 " 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\");"
2042 );
2043 }
2044 "is_false" => {
2045 let _ = writeln!(
2046 out,
2047 " 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\");"
2048 );
2049 }
2050 _ => {
2051 let _ = writeln!(
2052 out,
2053 " // unsupported assertion type on synthetic field chunks_have_content"
2054 );
2055 }
2056 }
2057 return;
2058 }
2059 "chunks_have_embeddings" => {
2060 match assertion.assertion_type.as_str() {
2061 "is_true" => {
2062 let _ = writeln!(
2063 out,
2064 " 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\");"
2065 );
2066 }
2067 "is_false" => {
2068 let _ = writeln!(
2069 out,
2070 " 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\");"
2071 );
2072 }
2073 _ => {
2074 let _ = writeln!(
2075 out,
2076 " // unsupported assertion type on synthetic field chunks_have_embeddings"
2077 );
2078 }
2079 }
2080 return;
2081 }
2082 "embeddings" => {
2086 let embed_list = result_var.to_string();
2089 match assertion.assertion_type.as_str() {
2090 "count_equals" => {
2091 if let Some(val) = &assertion.value {
2092 if let Some(n) = val.as_u64() {
2093 let _ = writeln!(
2094 out,
2095 " assert_eq!({embed_list}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {embed_list}.len());"
2096 );
2097 }
2098 }
2099 }
2100 "count_min" => {
2101 if let Some(val) = &assertion.value {
2102 if let Some(n) = val.as_u64() {
2103 if n <= 1 {
2104 let _ =
2105 writeln!(out, " assert!(!{embed_list}.is_empty(), \"expected >= {n}\");");
2106 } else {
2107 let _ = writeln!(
2108 out,
2109 " assert!({embed_list}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {embed_list}.len());"
2110 );
2111 }
2112 }
2113 }
2114 }
2115 "not_empty" => {
2116 let _ = writeln!(
2117 out,
2118 " assert!(!{embed_list}.is_empty(), \"expected non-empty embeddings\");"
2119 );
2120 }
2121 "is_empty" => {
2122 let _ = writeln!(
2123 out,
2124 " assert!({embed_list}.is_empty(), \"expected empty embeddings\");"
2125 );
2126 }
2127 _ => {
2128 let _ = writeln!(
2129 out,
2130 " // skipped: unsupported assertion type on synthetic field 'embeddings'"
2131 );
2132 }
2133 }
2134 return;
2135 }
2136 "embedding_dimensions" => {
2137 let embed_list = result_var;
2138 let expr = format!("{embed_list}.first().map_or(0, |e| e.len())");
2139 match assertion.assertion_type.as_str() {
2140 "equals" => {
2141 if let Some(val) = &assertion.value {
2142 let lit = numeric_literal(val);
2143 let _ = writeln!(
2144 out,
2145 " assert_eq!({expr}, {lit} as usize, \"equals assertion failed\");"
2146 );
2147 }
2148 }
2149 "greater_than" => {
2150 if let Some(val) = &assertion.value {
2151 let lit = numeric_literal(val);
2152 let _ = writeln!(out, " assert!({expr} > {lit} as usize, \"expected > {lit}\");");
2153 }
2154 }
2155 _ => {
2156 let _ = writeln!(
2157 out,
2158 " // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
2159 );
2160 }
2161 }
2162 return;
2163 }
2164 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
2165 let embed_list = result_var;
2166 let pred = match f.as_str() {
2167 "embeddings_valid" => {
2168 format!("{embed_list}.iter().all(|e| !e.is_empty())")
2169 }
2170 "embeddings_finite" => {
2171 format!("{embed_list}.iter().all(|e| e.iter().all(|v| v.is_finite()))")
2172 }
2173 "embeddings_non_zero" => {
2174 format!("{embed_list}.iter().all(|e| e.iter().any(|v| *v != 0.0_f32))")
2175 }
2176 "embeddings_normalized" => {
2177 format!(
2178 "{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 }})"
2179 )
2180 }
2181 _ => unreachable!(),
2182 };
2183 match assertion.assertion_type.as_str() {
2184 "is_true" => {
2185 let _ = writeln!(out, " assert!({pred}, \"expected true\");");
2186 }
2187 "is_false" => {
2188 let _ = writeln!(out, " assert!(!({pred}), \"expected false\");");
2189 }
2190 _ => {
2191 let _ = writeln!(
2192 out,
2193 " // skipped: unsupported assertion type on synthetic field '{f}'"
2194 );
2195 }
2196 }
2197 return;
2198 }
2199 "keywords" => {
2203 let accessor = format!("{result_var}.extracted_keywords");
2204 match assertion.assertion_type.as_str() {
2205 "not_empty" => {
2206 let _ = writeln!(
2207 out,
2208 " assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected keywords to be present and non-empty\");"
2209 );
2210 }
2211 "is_empty" => {
2212 let _ = writeln!(
2213 out,
2214 " assert!({accessor}.as_ref().is_none_or(|v| v.is_empty()), \"expected keywords to be empty or absent\");"
2215 );
2216 }
2217 "count_min" => {
2218 if let Some(val) = &assertion.value {
2219 if let Some(n) = val.as_u64() {
2220 if n <= 1 {
2221 let _ = writeln!(
2222 out,
2223 " assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected >= {n}\");"
2224 );
2225 } else {
2226 let _ = writeln!(
2227 out,
2228 " assert!({accessor}.as_ref().is_some_and(|v| v.len() >= {n}), \"expected at least {n} keywords\");"
2229 );
2230 }
2231 }
2232 }
2233 }
2234 "count_equals" => {
2235 if let Some(val) = &assertion.value {
2236 if let Some(n) = val.as_u64() {
2237 let _ = writeln!(
2238 out,
2239 " assert!({accessor}.as_ref().is_some_and(|v| v.len() == {n}), \"expected exactly {n} keywords\");"
2240 );
2241 }
2242 }
2243 }
2244 _ => {
2245 let _ = writeln!(
2246 out,
2247 " // skipped: unsupported assertion type on synthetic field 'keywords'"
2248 );
2249 }
2250 }
2251 return;
2252 }
2253 "keywords_count" => {
2254 let expr = format!("{result_var}.extracted_keywords.as_ref().map_or(0, |v| v.len())");
2255 match assertion.assertion_type.as_str() {
2256 "equals" => {
2257 if let Some(val) = &assertion.value {
2258 let lit = numeric_literal(val);
2259 let _ = writeln!(
2260 out,
2261 " assert_eq!({expr}, {lit} as usize, \"equals assertion failed\");"
2262 );
2263 }
2264 }
2265 "less_than_or_equal" => {
2266 if let Some(val) = &assertion.value {
2267 let lit = numeric_literal(val);
2268 let _ = writeln!(out, " assert!({expr} <= {lit} as usize, \"expected <= {lit}\");");
2269 }
2270 }
2271 "greater_than_or_equal" => {
2272 if let Some(val) = &assertion.value {
2273 let lit = numeric_literal(val);
2274 let _ = writeln!(out, " assert!({expr} >= {lit} as usize, \"expected >= {lit}\");");
2275 }
2276 }
2277 "greater_than" => {
2278 if let Some(val) = &assertion.value {
2279 let lit = numeric_literal(val);
2280 let _ = writeln!(out, " assert!({expr} > {lit} as usize, \"expected > {lit}\");");
2281 }
2282 }
2283 "less_than" => {
2284 if let Some(val) = &assertion.value {
2285 let lit = numeric_literal(val);
2286 let _ = writeln!(out, " assert!({expr} < {lit} as usize, \"expected < {lit}\");");
2287 }
2288 }
2289 _ => {
2290 let _ = writeln!(
2291 out,
2292 " // skipped: unsupported assertion type on synthetic field 'keywords_count'"
2293 );
2294 }
2295 }
2296 return;
2297 }
2298 _ => {}
2299 }
2300 }
2301
2302 if let Some(f) = &assertion.field {
2304 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
2305 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
2306 return;
2307 }
2308 }
2309
2310 let field_access = match &assertion.field {
2318 Some(f) if !f.is_empty() => {
2319 if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
2320 local_var.clone()
2321 } else if result_is_simple {
2322 result_var.to_string()
2325 } else if f == result_var {
2326 result_var.to_string()
2329 } else if result_is_tree {
2330 tree_field_access_expr(f, result_var, module)
2333 } else {
2334 field_resolver.accessor(f, "rust", result_var)
2335 }
2336 }
2337 _ => result_var.to_string(),
2338 };
2339
2340 let is_unwrapped = assertion
2342 .field
2343 .as_ref()
2344 .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
2345
2346 match assertion.assertion_type.as_str() {
2347 "error" => {
2348 let _ = writeln!(out, " assert!({result_var}.is_err(), \"expected call to fail\");");
2349 if let Some(serde_json::Value::String(msg)) = &assertion.value {
2350 let escaped = escape_rust(msg);
2351 let _ = writeln!(
2352 out,
2353 " assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
2354 );
2355 }
2356 }
2357 "not_error" => {
2358 }
2360 "equals" => {
2361 if let Some(val) = &assertion.value {
2362 let expected = value_to_rust_string(val);
2363 if is_error_context {
2364 return;
2365 }
2366 if val.is_string() {
2369 let is_opt_str_not_unwrapped = assertion.field.as_ref().is_some_and(|f| {
2374 let resolved = field_resolver.resolve(f);
2375 let is_opt = field_resolver.is_optional(resolved);
2376 let is_arr = field_resolver.is_array(resolved);
2377 is_opt && !is_arr && !is_unwrapped
2378 });
2379 let field_expr = if is_opt_str_not_unwrapped {
2380 format!("{field_access}.as_deref().unwrap_or(\"\").trim()")
2381 } else {
2382 format!("{field_access}.trim()")
2383 };
2384 let _ = writeln!(
2385 out,
2386 " assert_eq!({field_expr}, {expected}, \"equals assertion failed\");"
2387 );
2388 } else if val.is_boolean() {
2389 if val.as_bool() == Some(true) {
2391 let _ = writeln!(out, " assert!({field_access}, \"equals assertion failed\");");
2392 } else {
2393 let _ = writeln!(out, " assert!(!{field_access}, \"equals assertion failed\");");
2394 }
2395 } else {
2396 let is_opt = assertion.field.as_ref().is_some_and(|f| {
2398 let resolved = field_resolver.resolve(f);
2399 field_resolver.is_optional(resolved)
2400 });
2401 if is_opt
2402 && !unwrapped_fields
2403 .iter()
2404 .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
2405 {
2406 let _ = writeln!(
2407 out,
2408 " assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
2409 );
2410 } else {
2411 let _ = writeln!(
2412 out,
2413 " assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
2414 );
2415 }
2416 }
2417 }
2418 }
2419 "contains" => {
2420 if let Some(val) = &assertion.value {
2421 let expected = value_to_rust_string(val);
2422 let line = format!(
2423 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
2424 );
2425 let _ = writeln!(out, "{line}");
2426 }
2427 }
2428 "contains_all" => {
2429 if let Some(values) = &assertion.values {
2430 for val in values {
2431 let expected = value_to_rust_string(val);
2432 let line = format!(
2433 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
2434 );
2435 let _ = writeln!(out, "{line}");
2436 }
2437 }
2438 }
2439 "not_contains" => {
2440 if let Some(val) = &assertion.value {
2441 let expected = value_to_rust_string(val);
2442 let line = format!(
2443 " assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
2444 );
2445 let _ = writeln!(out, "{line}");
2446 }
2447 }
2448 "not_empty" => {
2449 if let Some(f) = &assertion.field {
2450 let resolved = field_resolver.resolve(f);
2451 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2452 let is_arr = field_resolver.is_array(resolved);
2453 if is_opt && is_arr {
2454 let accessor = field_resolver.accessor(f, "rust", result_var);
2456 let _ = writeln!(
2457 out,
2458 " assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected {f} to be present and non-empty\");"
2459 );
2460 } else if is_opt {
2461 let accessor = field_resolver.accessor(f, "rust", result_var);
2463 let _ = writeln!(
2464 out,
2465 " assert!({accessor}.is_some(), \"expected {f} to be present\");"
2466 );
2467 } else {
2468 let _ = writeln!(
2469 out,
2470 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
2471 );
2472 }
2473 } else if result_is_option {
2474 let _ = writeln!(
2476 out,
2477 " assert!({field_access}.is_some(), \"expected non-empty value\");"
2478 );
2479 } else {
2480 let _ = writeln!(
2482 out,
2483 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
2484 );
2485 }
2486 }
2487 "is_empty" => {
2488 if let Some(f) = &assertion.field {
2489 let resolved = field_resolver.resolve(f);
2490 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2491 let is_arr = field_resolver.is_array(resolved);
2492 if is_opt && is_arr {
2493 let accessor = field_resolver.accessor(f, "rust", result_var);
2495 let _ = writeln!(
2496 out,
2497 " assert!({accessor}.as_ref().is_none_or(|v| v.is_empty()), \"expected {f} to be empty or absent\");"
2498 );
2499 } else if is_opt {
2500 let accessor = field_resolver.accessor(f, "rust", result_var);
2501 let _ = writeln!(out, " assert!({accessor}.is_none(), \"expected {f} to be absent\");");
2502 } else {
2503 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
2504 }
2505 } else {
2506 let _ = writeln!(out, " assert!({field_access}.is_none(), \"expected empty value\");");
2507 }
2508 }
2509 "contains_any" => {
2510 if let Some(values) = &assertion.values {
2511 let checks: Vec<String> = values
2512 .iter()
2513 .map(|v| {
2514 let expected = value_to_rust_string(v);
2515 format!("{field_access}.contains({expected})")
2516 })
2517 .collect();
2518 let joined = checks.join(" || ");
2519 let _ = writeln!(
2520 out,
2521 " assert!({joined}, \"expected to contain at least one of the specified values\");"
2522 );
2523 }
2524 }
2525 "greater_than" => {
2526 if let Some(val) = &assertion.value {
2527 if val.as_f64().is_some_and(|n| n < 0.0) {
2529 let _ = writeln!(
2530 out,
2531 " // skipped: greater_than with negative value is always true for unsigned types"
2532 );
2533 } else if val.as_u64() == Some(0) {
2534 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2536 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected > 0\");");
2537 } else {
2538 let lit = numeric_literal(val);
2539 let _ = writeln!(out, " assert!({field_access} > {lit}, \"expected > {lit}\");");
2540 }
2541 }
2542 }
2543 "less_than" => {
2544 if let Some(val) = &assertion.value {
2545 let lit = numeric_literal(val);
2546 let _ = writeln!(out, " assert!({field_access} < {lit}, \"expected < {lit}\");");
2547 }
2548 }
2549 "greater_than_or_equal" => {
2550 if let Some(val) = &assertion.value {
2551 let lit = numeric_literal(val);
2552 let is_opt_numeric = assertion.field.as_ref().is_some_and(|f| {
2555 let resolved = field_resolver.resolve(f);
2556 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2557 let is_arr = field_resolver.is_array(resolved);
2558 is_opt && !is_arr
2559 });
2560 if val.as_u64() == Some(1) && field_access.ends_with(".len()") {
2561 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2565 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= 1\");");
2566 } else if is_opt_numeric {
2567 let _ = writeln!(
2569 out,
2570 " assert!({field_access}.unwrap_or(0) >= {lit}, \"expected >= {lit}\");"
2571 );
2572 } else {
2573 let _ = writeln!(out, " assert!({field_access} >= {lit}, \"expected >= {lit}\");");
2574 }
2575 }
2576 }
2577 "less_than_or_equal" => {
2578 if let Some(val) = &assertion.value {
2579 let lit = numeric_literal(val);
2580 let _ = writeln!(out, " assert!({field_access} <= {lit}, \"expected <= {lit}\");");
2581 }
2582 }
2583 "starts_with" => {
2584 if let Some(val) = &assertion.value {
2585 let expected = value_to_rust_string(val);
2586 let _ = writeln!(
2587 out,
2588 " assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
2589 );
2590 }
2591 }
2592 "ends_with" => {
2593 if let Some(val) = &assertion.value {
2594 let expected = value_to_rust_string(val);
2595 let _ = writeln!(
2596 out,
2597 " assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
2598 );
2599 }
2600 }
2601 "min_length" => {
2602 if let Some(val) = &assertion.value {
2603 if let Some(n) = val.as_u64() {
2604 let _ = writeln!(
2605 out,
2606 " assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
2607 );
2608 }
2609 }
2610 }
2611 "max_length" => {
2612 if let Some(val) = &assertion.value {
2613 if let Some(n) = val.as_u64() {
2614 let _ = writeln!(
2615 out,
2616 " assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
2617 );
2618 }
2619 }
2620 }
2621 "count_min" => {
2622 if let Some(val) = &assertion.value {
2623 if let Some(n) = val.as_u64() {
2624 let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
2625 let resolved = field_resolver.resolve(f);
2626 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2627 let is_arr = field_resolver.is_array(resolved);
2628 is_opt && is_arr
2629 });
2630 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2631 if opt_arr_field {
2632 if n <= 1 {
2634 let _ = writeln!(
2635 out,
2636 " assert!({base}.as_ref().is_some_and(|v| !v.is_empty()), \"expected >= {n}\");"
2637 );
2638 } else {
2639 let _ = writeln!(
2640 out,
2641 " assert!({base}.as_ref().is_some_and(|v| v.len() >= {n}), \"expected at least {n} elements\");"
2642 );
2643 }
2644 } else if n <= 1 {
2645 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= {n}\");");
2646 } else {
2647 let _ = writeln!(
2648 out,
2649 " assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
2650 );
2651 }
2652 }
2653 }
2654 }
2655 "count_equals" => {
2656 if let Some(val) = &assertion.value {
2657 if let Some(n) = val.as_u64() {
2658 let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
2659 let resolved = field_resolver.resolve(f);
2660 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2661 let is_arr = field_resolver.is_array(resolved);
2662 is_opt && is_arr
2663 });
2664 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2665 if opt_arr_field {
2666 let _ = writeln!(
2667 out,
2668 " assert!({base}.as_ref().is_some_and(|v| v.len() == {n}), \"expected exactly {n} elements\");"
2669 );
2670 } else {
2671 let _ = writeln!(
2672 out,
2673 " assert_eq!({field_access}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {field_access}.len());"
2674 );
2675 }
2676 }
2677 }
2678 }
2679 "is_true" => {
2680 let _ = writeln!(out, " assert!({field_access}, \"expected true\");");
2681 }
2682 "is_false" => {
2683 let _ = writeln!(out, " assert!(!{field_access}, \"expected false\");");
2684 }
2685 "method_result" => {
2686 if let Some(method_name) = &assertion.method {
2687 let call_expr = if result_is_tree {
2691 build_tree_call_expr(field_access.as_str(), method_name, assertion.args.as_ref(), module)
2692 } else if let Some(args) = &assertion.args {
2693 let arg_lit = json_to_rust_literal(args, "");
2694 format!("{field_access}.{method_name}({arg_lit})")
2695 } else {
2696 format!("{field_access}.{method_name}()")
2697 };
2698
2699 let returns_numeric = result_is_tree && is_tree_numeric_method(method_name);
2702
2703 let check = assertion.check.as_deref().unwrap_or("is_true");
2704 match check {
2705 "equals" => {
2706 if let Some(val) = &assertion.value {
2707 if val.is_boolean() {
2708 if val.as_bool() == Some(true) {
2709 let _ = writeln!(
2710 out,
2711 " assert!({call_expr}, \"method_result equals assertion failed\");"
2712 );
2713 } else {
2714 let _ = writeln!(
2715 out,
2716 " assert!(!{call_expr}, \"method_result equals assertion failed\");"
2717 );
2718 }
2719 } else {
2720 let expected = value_to_rust_string(val);
2721 let _ = writeln!(
2722 out,
2723 " assert_eq!({call_expr}, {expected}, \"method_result equals assertion failed\");"
2724 );
2725 }
2726 }
2727 }
2728 "is_true" => {
2729 let _ = writeln!(
2730 out,
2731 " assert!({call_expr}, \"method_result is_true assertion failed\");"
2732 );
2733 }
2734 "is_false" => {
2735 let _ = writeln!(
2736 out,
2737 " assert!(!{call_expr}, \"method_result is_false assertion failed\");"
2738 );
2739 }
2740 "greater_than_or_equal" => {
2741 if let Some(val) = &assertion.value {
2742 let lit = numeric_literal(val);
2743 if returns_numeric {
2744 let _ = writeln!(out, " assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
2746 } else if val.as_u64() == Some(1) {
2747 let _ = writeln!(out, " assert!(!{call_expr}.is_empty(), \"expected >= 1\");");
2749 } else {
2750 let _ = writeln!(out, " assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
2751 }
2752 }
2753 }
2754 "count_min" => {
2755 if let Some(val) = &assertion.value {
2756 let n = val.as_u64().unwrap_or(0);
2757 if n <= 1 {
2758 let _ = writeln!(out, " assert!(!{call_expr}.is_empty(), \"expected >= {n}\");");
2759 } else {
2760 let _ = writeln!(
2761 out,
2762 " assert!({call_expr}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {call_expr}.len());"
2763 );
2764 }
2765 }
2766 }
2767 "is_error" => {
2768 let raw_call = call_expr.strip_suffix(".unwrap()").unwrap_or(&call_expr);
2770 let _ = writeln!(
2771 out,
2772 " assert!({raw_call}.is_err(), \"expected method to return error\");"
2773 );
2774 }
2775 "contains" => {
2776 if let Some(val) = &assertion.value {
2777 let expected = value_to_rust_string(val);
2778 let _ = writeln!(
2779 out,
2780 " assert!({call_expr}.contains({expected}), \"expected result to contain {{}}\", {expected});"
2781 );
2782 }
2783 }
2784 "not_empty" => {
2785 let _ = writeln!(
2786 out,
2787 " assert!(!{call_expr}.is_empty(), \"expected non-empty result\");"
2788 );
2789 }
2790 "is_empty" => {
2791 let _ = writeln!(out, " assert!({call_expr}.is_empty(), \"expected empty result\");");
2792 }
2793 other_check => {
2794 panic!("Rust e2e generator: unsupported method_result check type: {other_check}");
2795 }
2796 }
2797 } else {
2798 panic!("Rust e2e generator: method_result assertion missing 'method' field");
2799 }
2800 }
2801 other => {
2802 panic!("Rust e2e generator: unsupported assertion type: {other}");
2803 }
2804 }
2805}
2806
2807fn tree_field_access_expr(field: &str, result_var: &str, module: &str) -> String {
2815 match field {
2816 "root_child_count" => format!("{result_var}.root_node().child_count()"),
2817 "root_node_type" => format!("{result_var}.root_node().kind()"),
2818 "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
2819 "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{result_var})"),
2820 "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{result_var})"),
2821 "tree_to_sexp" => format!("{module}::tree_to_sexp(&{result_var})"),
2822 other => format!("{result_var}.{other}"),
2825 }
2826}
2827
2828fn build_tree_call_expr(
2835 field_access: &str,
2836 method_name: &str,
2837 args: Option<&serde_json::Value>,
2838 module: &str,
2839) -> String {
2840 match method_name {
2841 "root_child_count" => format!("{field_access}.root_node().child_count()"),
2842 "root_node_type" => format!("{field_access}.root_node().kind()"),
2843 "named_children_count" => format!("{field_access}.root_node().named_child_count()"),
2844 "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{field_access})"),
2845 "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{field_access})"),
2846 "tree_to_sexp" => format!("{module}::tree_to_sexp(&{field_access})"),
2847 "contains_node_type" => {
2848 let node_type = args
2849 .and_then(|a| a.get("node_type"))
2850 .and_then(|v| v.as_str())
2851 .unwrap_or("");
2852 format!("{module}::tree_contains_node_type(&{field_access}, \"{node_type}\")")
2853 }
2854 "find_nodes_by_type" => {
2855 let node_type = args
2856 .and_then(|a| a.get("node_type"))
2857 .and_then(|v| v.as_str())
2858 .unwrap_or("");
2859 format!("{module}::find_nodes_by_type(&{field_access}, \"{node_type}\")")
2860 }
2861 "run_query" => {
2862 let query_source = args
2863 .and_then(|a| a.get("query_source"))
2864 .and_then(|v| v.as_str())
2865 .unwrap_or("");
2866 let language = args
2867 .and_then(|a| a.get("language"))
2868 .and_then(|v| v.as_str())
2869 .unwrap_or("");
2870 format!(
2873 "{module}::run_query(&{field_access}, \"{language}\", r#\"{query_source}\"#, source.as_bytes()).unwrap()"
2874 )
2875 }
2876 _ => {
2878 if let Some(args) = args {
2879 let arg_lit = json_to_rust_literal(args, "");
2880 format!("{field_access}.{method_name}({arg_lit})")
2881 } else {
2882 format!("{field_access}.{method_name}()")
2883 }
2884 }
2885 }
2886}
2887
2888fn is_tree_numeric_method(method_name: &str) -> bool {
2892 matches!(
2893 method_name,
2894 "root_child_count" | "named_children_count" | "error_count" | "tree_error_count"
2895 )
2896}
2897
2898fn numeric_literal(value: &serde_json::Value) -> String {
2904 if let Some(n) = value.as_f64() {
2905 if n.fract() == 0.0 {
2906 return format!("{}", n as i64);
2909 }
2910 return format!("{n}_f64");
2911 }
2912 value.to_string()
2914}
2915
2916fn value_to_rust_string(value: &serde_json::Value) -> String {
2917 match value {
2918 serde_json::Value::String(s) => rust_raw_string(s),
2919 serde_json::Value::Bool(b) => format!("{b}"),
2920 serde_json::Value::Number(n) => n.to_string(),
2921 other => {
2922 let s = other.to_string();
2923 format!("\"{s}\"")
2924 }
2925 }
2926}
2927
2928fn resolve_visitor_trait(module: &str) -> String {
2934 if module.contains("html_to_markdown") {
2936 "HtmlVisitor".to_string()
2937 } else {
2938 "Visitor".to_string()
2940 }
2941}
2942
2943fn emit_rust_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
2951 let params = match method_name {
2955 "visit_link" => "_: &NodeContext, _: &str, _: &str, _: &str",
2956 "visit_image" => "_: &NodeContext, _: &str, _: &str, _: &str",
2957 "visit_heading" => "_: &NodeContext, _: u8, _: &str, _: Option<&str>",
2958 "visit_code_block" => "_: &NodeContext, _: Option<&str>, _: &str",
2959 "visit_code_inline"
2960 | "visit_strong"
2961 | "visit_emphasis"
2962 | "visit_strikethrough"
2963 | "visit_underline"
2964 | "visit_subscript"
2965 | "visit_superscript"
2966 | "visit_mark"
2967 | "visit_button"
2968 | "visit_summary"
2969 | "visit_figcaption"
2970 | "visit_definition_term"
2971 | "visit_definition_description" => "_: &NodeContext, _: &str",
2972 "visit_text" => "_: &NodeContext, _: &str",
2973 "visit_list_item" => "_: &NodeContext, _: bool, _: &str, _: &str",
2974 "visit_blockquote" => "_: &NodeContext, _: &str, _: u32",
2975 "visit_table_row" => "_: &NodeContext, _: &[String], _: bool",
2976 "visit_custom_element" => "_: &NodeContext, _: &str, _: &str",
2977 "visit_form" => "_: &NodeContext, _: &str, _: &str",
2978 "visit_input" => "_: &NodeContext, _: &str, _: &str, _: &str",
2979 "visit_audio" | "visit_video" | "visit_iframe" => "_: &NodeContext, _: &str",
2980 "visit_details" => "_: &NodeContext, _: bool",
2981 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
2982 "_: &NodeContext, _: &str"
2983 }
2984 "visit_list_start" => "_: &NodeContext, _: bool",
2985 "visit_list_end" => "_: &NodeContext, _: bool, _: &str",
2986 _ => "_: &NodeContext",
2987 };
2988
2989 let _ = writeln!(out, " fn {method_name}(&mut self, {params}) -> VisitResult {{");
2990 match action {
2991 CallbackAction::Skip => {
2992 let _ = writeln!(out, " VisitResult::Skip");
2993 }
2994 CallbackAction::Continue => {
2995 let _ = writeln!(out, " VisitResult::Continue");
2996 }
2997 CallbackAction::PreserveHtml => {
2998 let _ = writeln!(out, " VisitResult::PreserveHtml");
2999 }
3000 CallbackAction::Custom { output } => {
3001 let escaped = escape_rust(output);
3002 let _ = writeln!(out, " VisitResult::Custom(\"{escaped}\".to_string())");
3003 }
3004 CallbackAction::CustomTemplate { template } => {
3005 let escaped = escape_rust(template);
3006 let _ = writeln!(out, " VisitResult::Custom(format!(\"{escaped}\"))");
3007 }
3008 }
3009 let _ = writeln!(out, " }}");
3010}