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 file_has_call_based = fixtures.iter().any(|f| f.mock_response.is_some());
357
358 if file_has_call_based {
361 let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
362 for fixture in fixtures.iter().filter(|f| f.mock_response.is_some()) {
363 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
364 let fn_name = resolve_function_name_for_call(call_config);
365 let mod_name = resolve_module_for_call(call_config, dep_name);
366 imported.insert((mod_name, fn_name));
367 }
368 let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
370 for (mod_name, fn_name) in &imported {
371 by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
372 }
373 for (mod_name, fns) in &by_module {
374 if fns.len() == 1 {
375 let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
376 } else {
377 let joined = fns.join(", ");
378 let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
379 }
380 }
381 }
382
383 if file_has_http {
385 let _ = writeln!(out, "use {module}::{{App, RequestContext}};");
386 }
387
388 let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
390 if has_handle_args {
391 let _ = writeln!(out, "use {module}::CrawlConfig;");
392 }
393 for arg in &e2e_config.call.args {
394 if arg.arg_type == "handle" {
395 use heck::ToSnakeCase;
396 let constructor_name = format!("create_{}", arg.name.to_snake_case());
397 let _ = writeln!(out, "use {module}::{constructor_name};");
398 }
399 }
400
401 let file_needs_mock = needs_mock_server && fixtures.iter().any(|f| f.mock_response.is_some());
403 if file_needs_mock {
404 let _ = writeln!(out, "mod mock_server;");
405 let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
406 }
407
408 let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
412 if file_needs_visitor {
413 let visitor_trait = resolve_visitor_trait(&module);
414 let _ = writeln!(out, "use {module}::{{{visitor_trait}, NodeContext, VisitResult}};");
415 }
416
417 let _ = writeln!(out);
418
419 for fixture in fixtures {
420 render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
421 let _ = writeln!(out);
422 }
423
424 if !out.ends_with('\n') {
425 out.push('\n');
426 }
427 out
428}
429
430fn render_test_function(
431 out: &mut String,
432 fixture: &Fixture,
433 e2e_config: &E2eConfig,
434 dep_name: &str,
435 field_resolver: &FieldResolver,
436) {
437 if fixture.http.is_some() {
439 render_http_test_function(out, fixture, dep_name);
440 return;
441 }
442
443 if fixture.http.is_none() && fixture.mock_response.is_none() {
448 let fn_name = sanitize_ident(&fixture.id);
449 let description = &fixture.description;
450 let _ = writeln!(out, "#[tokio::test]");
451 let _ = writeln!(out, "async fn test_{fn_name}() {{");
452 let _ = writeln!(out, " // {description}");
453 let _ = writeln!(
454 out,
455 " // TODO: implement when a callable API is available for this fixture type."
456 );
457 let _ = writeln!(out, "}}");
458 return;
459 }
460
461 let fn_name = sanitize_ident(&fixture.id);
462 let description = &fixture.description;
463 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
464 let function_name = resolve_function_name_for_call(call_config);
465 let module = resolve_module_for_call(call_config, dep_name);
466 let result_var = &call_config.result_var;
467 let has_mock = fixture.mock_response.is_some();
468
469 let is_async = call_config.r#async || has_mock;
471 if is_async {
472 let _ = writeln!(out, "#[tokio::test]");
473 let _ = writeln!(out, "async fn test_{fn_name}() {{");
474 } else {
475 let _ = writeln!(out, "#[test]");
476 let _ = writeln!(out, "fn test_{fn_name}() {{");
477 }
478 let _ = writeln!(out, " // {description}");
479
480 if has_mock {
483 render_mock_server_setup(out, fixture, e2e_config);
484 }
485
486 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
488
489 let rust_overrides = call_config.overrides.get("rust");
491 let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
492 let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
493
494 let mut arg_exprs: Vec<String> = Vec::new();
496 for arg in &call_config.args {
497 let value = resolve_field(&fixture.input, &arg.field);
498 let var_name = &arg.name;
499 let (bindings, expr) = render_rust_arg(
500 var_name,
501 value,
502 &arg.arg_type,
503 arg.optional,
504 &module,
505 &fixture.id,
506 if has_mock {
507 Some("mock_server.url.as_str()")
508 } else {
509 None
510 },
511 arg.owned,
512 arg.element_type.as_deref(),
513 );
514 for binding in &bindings {
515 let _ = writeln!(out, " {binding}");
516 }
517 let final_expr = if wrap_options_in_some && arg.arg_type == "json_object" {
521 if let Some(rest) = expr.strip_prefix('&') {
522 format!("Some({rest}.clone())")
523 } else {
524 format!("Some({expr})")
525 }
526 } else {
527 expr
528 };
529 arg_exprs.push(final_expr);
530 }
531
532 if let Some(visitor_spec) = &fixture.visitor {
534 let _ = writeln!(out, " struct _TestVisitor;");
535 let _ = writeln!(out, " impl {} for _TestVisitor {{", resolve_visitor_trait(&module));
536 for (method_name, action) in &visitor_spec.callbacks {
537 emit_rust_visitor_method(out, method_name, action);
538 }
539 let _ = writeln!(out, " }}");
540 let _ = writeln!(
541 out,
542 " let visitor = std::rc::Rc::new(std::cell::RefCell::new(_TestVisitor));"
543 );
544 arg_exprs.push("Some(visitor)".to_string());
545 } else {
546 arg_exprs.extend(extra_args);
549 }
550
551 let args_str = arg_exprs.join(", ");
552
553 let await_suffix = if is_async { ".await" } else { "" };
554
555 let result_is_tree = call_config.result_var == "tree";
556 let result_is_simple = rust_overrides.is_some_and(|o| o.result_is_simple);
559 let result_is_vec = rust_overrides.is_some_and(|o| o.result_is_vec);
562 let result_is_option = rust_overrides.is_some_and(|o| o.result_is_option);
565
566 if has_error_assertion {
567 let _ = writeln!(out, " let {result_var} = {function_name}({args_str}){await_suffix};");
568 for assertion in &fixture.assertions {
570 render_assertion(
571 out,
572 assertion,
573 result_var,
574 &module,
575 dep_name,
576 true,
577 &[],
578 field_resolver,
579 result_is_tree,
580 result_is_simple,
581 false,
582 false,
583 );
584 }
585 let _ = writeln!(out, "}}");
586 return;
587 }
588
589 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
591
592 let has_usable_assertion = fixture.assertions.iter().any(|a| {
596 if a.assertion_type == "not_error" || a.assertion_type == "error" {
597 return false;
598 }
599 if a.assertion_type == "method_result" {
600 let supported_checks = [
603 "equals",
604 "is_true",
605 "is_false",
606 "greater_than_or_equal",
607 "count_min",
608 "is_error",
609 "contains",
610 "not_empty",
611 "is_empty",
612 ];
613 let check = a.check.as_deref().unwrap_or("is_true");
614 if a.method.is_none() || !supported_checks.contains(&check) {
615 return false;
616 }
617 }
618 match &a.field {
619 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
620 _ => true,
621 }
622 });
623
624 let result_binding = if has_usable_assertion {
625 result_var.to_string()
626 } else {
627 "_".to_string()
628 };
629
630 let has_field_access = fixture
634 .assertions
635 .iter()
636 .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
637 let only_emptiness_checks = !has_field_access
638 && fixture.assertions.iter().all(|a| {
639 matches!(
640 a.assertion_type.as_str(),
641 "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
642 )
643 });
644
645 let returns_result = rust_overrides
648 .and_then(|o| o.returns_result)
649 .unwrap_or(call_config.returns_result);
650
651 let unwrap_suffix = if returns_result {
652 ".expect(\"should succeed\")"
653 } else {
654 ""
655 };
656 if only_emptiness_checks || !returns_result {
657 let _ = writeln!(
659 out,
660 " let {result_binding} = {function_name}({args_str}){await_suffix};"
661 );
662 } else if has_not_error || !fixture.assertions.is_empty() {
663 let _ = writeln!(
664 out,
665 " let {result_binding} = {function_name}({args_str}){await_suffix}{unwrap_suffix};"
666 );
667 } else {
668 let _ = writeln!(
669 out,
670 " let {result_binding} = {function_name}({args_str}){await_suffix};"
671 );
672 }
673
674 let string_assertion_types = [
680 "equals",
681 "contains",
682 "contains_all",
683 "contains_any",
684 "not_contains",
685 "starts_with",
686 "ends_with",
687 "min_length",
688 "max_length",
689 "matches_regex",
690 ];
691 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); if !result_is_vec {
693 for assertion in &fixture.assertions {
694 if let Some(f) = &assertion.field {
695 if !f.is_empty()
696 && string_assertion_types.contains(&assertion.assertion_type.as_str())
697 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
698 {
699 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
702 if !is_string_assertion {
703 continue;
704 }
705 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
706 let _ = writeln!(out, " {binding}");
707 unwrapped_fields.push((f.clone(), local_var));
708 }
709 }
710 }
711 }
712 }
713
714 for assertion in &fixture.assertions {
716 if assertion.assertion_type == "not_error" {
717 continue;
719 }
720 render_assertion(
721 out,
722 assertion,
723 result_var,
724 &module,
725 dep_name,
726 false,
727 &unwrapped_fields,
728 field_resolver,
729 result_is_tree,
730 result_is_simple,
731 result_is_vec,
732 result_is_option,
733 );
734 }
735
736 let _ = writeln!(out, "}}");
737}
738
739#[allow(clippy::too_many_arguments)]
744fn render_rust_arg(
745 name: &str,
746 value: &serde_json::Value,
747 arg_type: &str,
748 optional: bool,
749 module: &str,
750 fixture_id: &str,
751 mock_base_url: Option<&str>,
752 owned: bool,
753 element_type: Option<&str>,
754) -> (Vec<String>, String) {
755 if arg_type == "mock_url" {
756 let lines = vec![format!(
757 "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
758 )];
759 return (lines, format!("&{name}"));
760 }
761 if arg_type == "base_url" {
763 if let Some(url_expr) = mock_base_url {
764 return (vec![], url_expr.to_string());
765 }
766 }
768 if arg_type == "handle" {
769 use heck::ToSnakeCase;
773 let constructor_name = format!("create_{}", name.to_snake_case());
774 let mut lines = Vec::new();
775 if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
776 lines.push(format!(
777 "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
778 ));
779 } else {
780 let json_literal = serde_json::to_string(value).unwrap_or_default();
782 let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
783 lines.push(format!(
784 "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
785 ));
786 lines.push(format!(
787 "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
788 ));
789 }
790 return (lines, format!("&{name}"));
791 }
792 if arg_type == "json_object" {
793 return render_json_object_arg(name, value, optional, owned, element_type, module);
794 }
795 if value.is_null() && !optional {
796 let default_val = match arg_type {
798 "string" => "String::new()".to_string(),
799 "int" | "integer" => "0".to_string(),
800 "float" | "number" => "0.0_f64".to_string(),
801 "bool" | "boolean" => "false".to_string(),
802 _ => "Default::default()".to_string(),
803 };
804 let expr = if arg_type == "string" {
806 format!("&{name}")
807 } else {
808 name.to_string()
809 };
810 return (vec![format!("let {name} = {default_val};")], expr);
811 }
812 let literal = json_to_rust_literal(value, arg_type);
813 let pass_by_ref = arg_type == "bytes";
816 let optional_expr = |n: &str| {
817 if arg_type == "string" {
818 format!("{n}.as_deref()")
819 } else if arg_type == "bytes" {
820 format!("{n}.as_deref().map(|v| v.as_slice())")
821 } else {
822 n.to_string()
826 }
827 };
828 let expr = |n: &str| {
829 if arg_type == "bytes" {
830 format!("{n}.as_bytes()")
831 } else if pass_by_ref {
832 format!("&{n}")
833 } else {
834 n.to_string()
835 }
836 };
837 if optional && value.is_null() {
838 let none_decl = match arg_type {
839 "string" => format!("let {name}: Option<String> = None;"),
840 "bytes" => format!("let {name}: Option<Vec<u8>> = None;"),
841 _ => format!("let {name} = None;"),
842 };
843 (vec![none_decl], optional_expr(name))
844 } else if optional {
845 (vec![format!("let {name} = Some({literal});")], optional_expr(name))
846 } else {
847 (vec![format!("let {name} = {literal};")], expr(name))
848 }
849}
850
851fn render_json_object_arg(
859 name: &str,
860 value: &serde_json::Value,
861 optional: bool,
862 owned: bool,
863 element_type: Option<&str>,
864 _module: &str,
865) -> (Vec<String>, String) {
866 let pass_by_ref = !owned;
868
869 if value.is_null() && optional {
870 let expr = if pass_by_ref {
872 format!("&{name}")
873 } else {
874 name.to_string()
875 };
876 return (vec![format!("let {name} = Default::default();")], expr);
877 }
878
879 let normalized = super::normalize_json_keys_to_snake_case(value);
882 let json_literal = json_value_to_macro_literal(&normalized);
884 let mut lines = Vec::new();
885 lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
886
887 let deser_expr = if let Some(elem) = element_type {
890 format!("serde_json::from_value::<Vec<{elem}>>({name}_json).unwrap()")
891 } else {
892 format!("serde_json::from_value({name}_json).unwrap()")
893 };
894
895 lines.push(format!("let {name} = {deser_expr};"));
898 let expr = if pass_by_ref {
899 format!("&{name}")
900 } else {
901 name.to_string()
902 };
903 (lines, expr)
904}
905
906fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
908 match value {
909 serde_json::Value::Null => "null".to_string(),
910 serde_json::Value::Bool(b) => format!("{b}"),
911 serde_json::Value::Number(n) => n.to_string(),
912 serde_json::Value::String(s) => {
913 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
914 format!("\"{escaped}\"")
915 }
916 serde_json::Value::Array(arr) => {
917 let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
918 format!("[{}]", items.join(", "))
919 }
920 serde_json::Value::Object(obj) => {
921 let entries: Vec<String> = obj
922 .iter()
923 .map(|(k, v)| {
924 let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
925 format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
926 })
927 .collect();
928 format!("{{{}}}", entries.join(", "))
929 }
930 }
931}
932
933fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
934 match value {
935 serde_json::Value::Null => "None".to_string(),
936 serde_json::Value::Bool(b) => format!("{b}"),
937 serde_json::Value::Number(n) => {
938 if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
939 if let Some(f) = n.as_f64() {
940 return format!("{f}_f64");
941 }
942 }
943 n.to_string()
944 }
945 serde_json::Value::String(s) => rust_raw_string(s),
946 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
947 let json_str = serde_json::to_string(value).unwrap_or_default();
948 let literal = rust_raw_string(&json_str);
949 format!("serde_json::from_str({literal}).unwrap()")
950 }
951 }
952}
953
954enum ServerCall<'a> {
960 Shorthand(&'a str),
962 AxumMethod(&'a str),
964}
965
966enum RouteRegistration<'a> {
968 Shorthand(&'a str),
970 Explicit(&'a str),
972}
973
974fn render_http_test_function(out: &mut String, fixture: &Fixture, dep_name: &str) {
980 let http = match &fixture.http {
981 Some(h) => h,
982 None => return,
983 };
984
985 let fn_name = sanitize_ident(&fixture.id);
986 let description = &fixture.description;
987
988 let route = &http.handler.route;
989
990 let route_reg = match http.handler.method.to_lowercase().as_str() {
993 "get" => RouteRegistration::Shorthand("get"),
994 "post" => RouteRegistration::Shorthand("post"),
995 "put" => RouteRegistration::Shorthand("put"),
996 "patch" => RouteRegistration::Shorthand("patch"),
997 "delete" => RouteRegistration::Shorthand("delete"),
998 "head" => RouteRegistration::Explicit("Head"),
999 "options" => RouteRegistration::Explicit("Options"),
1000 "trace" => RouteRegistration::Explicit("Trace"),
1001 _ => RouteRegistration::Shorthand("get"),
1002 };
1003
1004 let server_call = match http.request.method.to_uppercase().as_str() {
1007 "GET" => ServerCall::Shorthand("get"),
1008 "POST" => ServerCall::Shorthand("post"),
1009 "PUT" => ServerCall::Shorthand("put"),
1010 "PATCH" => ServerCall::Shorthand("patch"),
1011 "DELETE" => ServerCall::Shorthand("delete"),
1012 "HEAD" => ServerCall::AxumMethod("HEAD"),
1013 "OPTIONS" => ServerCall::AxumMethod("OPTIONS"),
1014 "TRACE" => ServerCall::AxumMethod("TRACE"),
1015 _ => ServerCall::Shorthand("get"),
1016 };
1017
1018 let req_path = &http.request.path;
1019 let status = http.expected_response.status_code;
1020
1021 let body_str = match &http.expected_response.body {
1023 Some(b) => serde_json::to_string(b).unwrap_or_else(|_| "{}".to_string()),
1024 None => String::new(),
1025 };
1026 let body_literal = rust_raw_string(&body_str);
1027
1028 let req_body_str = match &http.request.body {
1030 Some(b) => serde_json::to_string(b).unwrap_or_else(|_| "{}".to_string()),
1031 None => String::new(),
1032 };
1033 let has_req_body = !req_body_str.is_empty();
1034
1035 let middleware = http.handler.middleware.as_ref();
1037 let cors_cfg: Option<&CorsConfig> = middleware.and_then(|m| m.cors.as_ref());
1038 let static_files_cfgs: Option<&Vec<StaticFilesConfig>> = middleware.and_then(|m| m.static_files.as_ref());
1039 let has_static_files = static_files_cfgs.is_some_and(|v| !v.is_empty());
1040
1041 let _ = writeln!(out, "#[tokio::test]");
1042 let _ = writeln!(out, "async fn test_{fn_name}() {{");
1043 let _ = writeln!(out, " // {description}");
1044
1045 if has_static_files {
1047 render_static_files_test(out, fixture, static_files_cfgs.unwrap(), &server_call, req_path, status);
1048 return;
1049 }
1050
1051 let _ = writeln!(out, " let expected_body = {body_literal}.to_string();");
1053 let _ = writeln!(out, " let mut app = {dep_name}::App::new();");
1054
1055 match &route_reg {
1057 RouteRegistration::Shorthand(method) => {
1058 let _ = writeln!(
1059 out,
1060 " app.route({dep_name}::{method}({route:?}), move |_ctx: {dep_name}::RequestContext| {{"
1061 );
1062 }
1063 RouteRegistration::Explicit(variant) => {
1064 let _ = writeln!(
1065 out,
1066 " app.route({dep_name}::RouteBuilder::new({dep_name}::Method::{variant}, {route:?}), move |_ctx: {dep_name}::RequestContext| {{"
1067 );
1068 }
1069 }
1070 let _ = writeln!(out, " let body = expected_body.clone();");
1071 let _ = writeln!(out, " async move {{");
1072 let _ = writeln!(out, " Ok(axum::http::Response::builder()");
1073 let _ = writeln!(out, " .status({status}u16)");
1074 let _ = writeln!(out, " .header(\"content-type\", \"application/json\")");
1075 let _ = writeln!(out, " .body(axum::body::Body::from(body))");
1076 let _ = writeln!(out, " .unwrap())");
1077 let _ = writeln!(out, " }}");
1078 let _ = writeln!(out, " }}).unwrap();");
1079
1080 let _ = writeln!(out, " let router = app.into_router().unwrap();");
1082 if let Some(cors) = cors_cfg {
1083 render_cors_layer(out, cors);
1084 }
1085 let _ = writeln!(out, " let server = axum_test::TestServer::new(router);");
1086
1087 match &server_call {
1089 ServerCall::Shorthand(method) => {
1090 let _ = writeln!(out, " let response = server.{method}({req_path:?})");
1091 }
1092 ServerCall::AxumMethod(method) => {
1093 let _ = writeln!(
1094 out,
1095 " let response = server.method(axum::http::Method::{method}, {req_path:?})"
1096 );
1097 }
1098 }
1099
1100 for (name, value) in &http.request.headers {
1102 let n = rust_raw_string(name);
1103 let v = rust_raw_string(value);
1104 let _ = writeln!(out, " .add_header({n}, {v})");
1105 }
1106
1107 if has_req_body {
1109 let req_body_literal = rust_raw_string(&req_body_str);
1110 let _ = writeln!(
1111 out,
1112 " .bytes(bytes::Bytes::copy_from_slice({req_body_literal}.as_bytes()))"
1113 );
1114 }
1115
1116 let _ = writeln!(out, " .await;");
1117
1118 if cors_cfg.is_some() && (200..300).contains(&status) {
1122 let _ = writeln!(
1123 out,
1124 " assert!(response.status_code().is_success(), \"expected CORS success status, got {{}}\", response.status_code());"
1125 );
1126 } else {
1127 let _ = writeln!(out, " assert_eq!(response.status_code().as_u16(), {status}u16);");
1128 }
1129
1130 let _ = writeln!(out, "}}");
1131}
1132
1133fn render_cors_layer(out: &mut String, cors: &CorsConfig) {
1138 let _ = writeln!(
1139 out,
1140 " // Apply CorsLayer from tower-http based on fixture CORS config."
1141 );
1142 let _ = writeln!(out, " use tower_http::cors::CorsLayer;");
1143 let _ = writeln!(out, " use axum::http::{{HeaderName, HeaderValue, Method}};");
1144 let _ = writeln!(out, " let cors_layer = CorsLayer::new()");
1145
1146 if cors.allow_origins.is_empty() {
1148 let _ = writeln!(out, " .allow_origin(tower_http::cors::Any)");
1149 } else {
1150 let _ = writeln!(out, " .allow_origin([");
1151 for origin in &cors.allow_origins {
1152 let _ = writeln!(out, " \"{origin}\".parse::<HeaderValue>().unwrap(),");
1153 }
1154 let _ = writeln!(out, " ])");
1155 }
1156
1157 if cors.allow_methods.is_empty() {
1159 let _ = writeln!(out, " .allow_methods(tower_http::cors::Any)");
1160 } else {
1161 let methods: Vec<String> = cors
1162 .allow_methods
1163 .iter()
1164 .map(|m| format!("Method::{}", m.to_uppercase()))
1165 .collect();
1166 let _ = writeln!(out, " .allow_methods([{}])", methods.join(", "));
1167 }
1168
1169 if cors.allow_headers.is_empty() {
1171 let _ = writeln!(out, " .allow_headers(tower_http::cors::Any)");
1172 } else {
1173 let headers: Vec<String> = cors
1174 .allow_headers
1175 .iter()
1176 .map(|h| {
1177 let lower = h.to_lowercase();
1178 match lower.as_str() {
1179 "content-type" => "axum::http::header::CONTENT_TYPE".to_string(),
1180 "authorization" => "axum::http::header::AUTHORIZATION".to_string(),
1181 "accept" => "axum::http::header::ACCEPT".to_string(),
1182 _ => format!("HeaderName::from_static(\"{lower}\")"),
1183 }
1184 })
1185 .collect();
1186 let _ = writeln!(out, " .allow_headers([{}])", headers.join(", "));
1187 }
1188
1189 if let Some(secs) = cors.max_age {
1191 let _ = writeln!(out, " .max_age(std::time::Duration::from_secs({secs}));");
1192 } else {
1193 let _ = writeln!(out, " ;");
1194 }
1195
1196 let _ = writeln!(out, " let router = router.layer(cors_layer);");
1197}
1198
1199fn render_static_files_test(
1204 out: &mut String,
1205 fixture: &Fixture,
1206 cfgs: &[StaticFilesConfig],
1207 server_call: &ServerCall<'_>,
1208 req_path: &str,
1209 status: u16,
1210) {
1211 let http = fixture.http.as_ref().unwrap();
1212
1213 let _ = writeln!(out, " use tower_http::services::ServeDir;");
1214 let _ = writeln!(out, " use axum::Router;");
1215 let _ = writeln!(out, " let tmp_dir = tempfile::tempdir().expect(\"tmp dir\");");
1216
1217 let _ = writeln!(out, " let mut router = Router::new();");
1219 for cfg in cfgs {
1220 for file in &cfg.files {
1221 let file_path = file.path.replace('\\', "/");
1222 let content = rust_raw_string(&file.content);
1223 if file_path.contains('/') {
1224 let parent: String = file_path.rsplitn(2, '/').last().unwrap_or("").to_string();
1225 let _ = writeln!(
1226 out,
1227 " std::fs::create_dir_all(tmp_dir.path().join(\"{parent}\")).unwrap();"
1228 );
1229 }
1230 let _ = writeln!(
1231 out,
1232 " std::fs::write(tmp_dir.path().join(\"{file_path}\"), {content}).unwrap();"
1233 );
1234 }
1235 let prefix = &cfg.route_prefix;
1236 let serve_dir_expr = if cfg.index_file {
1237 "ServeDir::new(tmp_dir.path()).append_index_html_on_directories(true)".to_string()
1238 } else {
1239 "ServeDir::new(tmp_dir.path())".to_string()
1240 };
1241 let _ = writeln!(out, " router = router.nest_service({prefix:?}, {serve_dir_expr});");
1242 }
1243
1244 let _ = writeln!(out, " let server = axum_test::TestServer::new(router);");
1245
1246 match server_call {
1248 ServerCall::Shorthand(method) => {
1249 let _ = writeln!(out, " let response = server.{method}({req_path:?})");
1250 }
1251 ServerCall::AxumMethod(method) => {
1252 let _ = writeln!(
1253 out,
1254 " let response = server.method(axum::http::Method::{method}, {req_path:?})"
1255 );
1256 }
1257 }
1258
1259 for (name, value) in &http.request.headers {
1261 let n = rust_raw_string(name);
1262 let v = rust_raw_string(value);
1263 let _ = writeln!(out, " .add_header({n}, {v})");
1264 }
1265
1266 let _ = writeln!(out, " .await;");
1267 let _ = writeln!(out, " assert_eq!(response.status_code().as_u16(), {status}u16);");
1268 let _ = writeln!(out, "}}");
1269}
1270
1271fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
1281 let mock = match fixture.mock_response.as_ref() {
1282 Some(m) => m,
1283 None => return,
1284 };
1285
1286 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
1288 let path = call_config.path.as_deref().unwrap_or("/");
1289 let method = call_config.method.as_deref().unwrap_or("POST");
1290
1291 let status = mock.status;
1292
1293 let mut header_entries: Vec<(&String, &String)> = mock.headers.iter().collect();
1295 header_entries.sort_by(|a, b| a.0.cmp(b.0));
1296 let render_headers = |out: &mut String| {
1297 let _ = writeln!(out, " headers: vec![");
1298 for (name, value) in &header_entries {
1299 let n = rust_raw_string(name);
1300 let v = rust_raw_string(value);
1301 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
1302 }
1303 let _ = writeln!(out, " ],");
1304 };
1305
1306 if let Some(chunks) = &mock.stream_chunks {
1307 let _ = writeln!(out, " let mock_route = MockRoute {{");
1309 let _ = writeln!(out, " path: \"{path}\",");
1310 let _ = writeln!(out, " method: \"{method}\",");
1311 let _ = writeln!(out, " status: {status},");
1312 let _ = writeln!(out, " body: String::new(),");
1313 let _ = writeln!(out, " stream_chunks: vec![");
1314 for chunk in chunks {
1315 let chunk_str = match chunk {
1316 serde_json::Value::String(s) => rust_raw_string(s),
1317 other => {
1318 let s = serde_json::to_string(other).unwrap_or_default();
1319 rust_raw_string(&s)
1320 }
1321 };
1322 let _ = writeln!(out, " {chunk_str}.to_string(),");
1323 }
1324 let _ = writeln!(out, " ],");
1325 render_headers(out);
1326 let _ = writeln!(out, " }};");
1327 } else {
1328 let body_str = match &mock.body {
1330 Some(b) => {
1331 let s = serde_json::to_string(b).unwrap_or_default();
1332 rust_raw_string(&s)
1333 }
1334 None => rust_raw_string("{}"),
1335 };
1336 let _ = writeln!(out, " let mock_route = MockRoute {{");
1337 let _ = writeln!(out, " path: \"{path}\",");
1338 let _ = writeln!(out, " method: \"{method}\",");
1339 let _ = writeln!(out, " status: {status},");
1340 let _ = writeln!(out, " body: {body_str}.to_string(),");
1341 let _ = writeln!(out, " stream_chunks: vec![],");
1342 render_headers(out);
1343 let _ = writeln!(out, " }};");
1344 }
1345
1346 let _ = writeln!(out, " let mock_server = MockServer::start(vec![mock_route]).await;");
1347}
1348
1349pub fn render_mock_server_module() -> String {
1351 hash::header(CommentStyle::DoubleSlash)
1354 + r#"//
1355// Minimal axum-based mock HTTP server for e2e tests.
1356
1357use std::net::SocketAddr;
1358use std::sync::Arc;
1359
1360use axum::Router;
1361use axum::body::Body;
1362use axum::extract::State;
1363use axum::http::{Request, StatusCode};
1364use axum::response::{IntoResponse, Response};
1365use tokio::net::TcpListener;
1366
1367/// A single mock route: match by path + method, return a configured response.
1368#[derive(Clone, Debug)]
1369pub struct MockRoute {
1370 /// URL path to match, e.g. `"/v1/chat/completions"`.
1371 pub path: &'static str,
1372 /// HTTP method to match, e.g. `"POST"` or `"GET"`.
1373 pub method: &'static str,
1374 /// HTTP status code to return.
1375 pub status: u16,
1376 /// Response body JSON string (used when `stream_chunks` is empty).
1377 pub body: String,
1378 /// Ordered SSE data payloads for streaming responses.
1379 /// Each entry becomes `data: <chunk>\n\n` in the response.
1380 /// A final `data: [DONE]\n\n` is always appended.
1381 pub stream_chunks: Vec<String>,
1382 /// Response headers to apply (name, value) pairs.
1383 /// Multiple entries with the same name produce multiple header lines.
1384 pub headers: Vec<(String, String)>,
1385}
1386
1387struct ServerState {
1388 routes: Vec<MockRoute>,
1389}
1390
1391pub struct MockServer {
1392 /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
1393 pub url: String,
1394 handle: tokio::task::JoinHandle<()>,
1395}
1396
1397impl MockServer {
1398 /// Start a mock server with the given routes. Binds to a random port on
1399 /// localhost and returns immediately once the server is listening.
1400 pub async fn start(routes: Vec<MockRoute>) -> Self {
1401 let state = Arc::new(ServerState { routes });
1402
1403 let app = Router::new().fallback(handle_request).with_state(state);
1404
1405 let listener = TcpListener::bind("127.0.0.1:0")
1406 .await
1407 .expect("Failed to bind mock server port");
1408 let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
1409 let url = format!("http://{addr}");
1410
1411 let handle = tokio::spawn(async move {
1412 axum::serve(listener, app).await.expect("Mock server failed");
1413 });
1414
1415 MockServer { url, handle }
1416 }
1417
1418 /// Stop the mock server.
1419 pub fn shutdown(self) {
1420 self.handle.abort();
1421 }
1422}
1423
1424impl Drop for MockServer {
1425 fn drop(&mut self) {
1426 self.handle.abort();
1427 }
1428}
1429
1430async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
1431 let path = req.uri().path().to_owned();
1432 let method = req.method().as_str().to_uppercase();
1433
1434 for route in &state.routes {
1435 if route.path == path && route.method.to_uppercase() == method {
1436 let status =
1437 StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1438
1439 if !route.stream_chunks.is_empty() {
1440 // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
1441 let mut sse = String::new();
1442 for chunk in &route.stream_chunks {
1443 sse.push_str("data: ");
1444 sse.push_str(chunk);
1445 sse.push_str("\n\n");
1446 }
1447 sse.push_str("data: [DONE]\n\n");
1448
1449 let mut builder = Response::builder()
1450 .status(status)
1451 .header("content-type", "text/event-stream")
1452 .header("cache-control", "no-cache");
1453 for (name, value) in &route.headers {
1454 builder = builder.header(name, value);
1455 }
1456 return builder.body(Body::from(sse)).unwrap().into_response();
1457 }
1458
1459 let mut builder =
1460 Response::builder().status(status).header("content-type", "application/json");
1461 for (name, value) in &route.headers {
1462 builder = builder.header(name, value);
1463 }
1464 return builder.body(Body::from(route.body.clone())).unwrap().into_response();
1465 }
1466 }
1467
1468 // No matching route → 404.
1469 Response::builder()
1470 .status(StatusCode::NOT_FOUND)
1471 .body(Body::from(format!("No mock route for {method} {path}")))
1472 .unwrap()
1473 .into_response()
1474}
1475"#
1476}
1477
1478pub fn render_mock_server_binary() -> String {
1490 hash::header(CommentStyle::DoubleSlash)
1491 + r#"//
1492// Standalone mock HTTP server binary for cross-language e2e tests.
1493// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
1494//
1495// Usage: mock-server [fixtures-dir]
1496// fixtures-dir defaults to "../../fixtures"
1497//
1498// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
1499// then blocks until stdin is closed (parent process exit triggers cleanup).
1500
1501use std::collections::HashMap;
1502use std::io::{self, BufRead};
1503use std::net::SocketAddr;
1504use std::path::Path;
1505use std::sync::Arc;
1506
1507use axum::Router;
1508use axum::body::Body;
1509use axum::extract::State;
1510use axum::http::{Request, StatusCode};
1511use axum::response::{IntoResponse, Response};
1512use serde::Deserialize;
1513use tokio::net::TcpListener;
1514
1515// ---------------------------------------------------------------------------
1516// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
1517// Supports both schemas:
1518// liter-llm: mock_response: { status, body, stream_chunks }
1519// spikard: http.expected_response: { status_code, body, headers }
1520// ---------------------------------------------------------------------------
1521
1522#[derive(Debug, Deserialize)]
1523struct MockResponse {
1524 status: u16,
1525 #[serde(default)]
1526 body: Option<serde_json::Value>,
1527 #[serde(default)]
1528 stream_chunks: Option<Vec<serde_json::Value>>,
1529 #[serde(default)]
1530 headers: HashMap<String, String>,
1531}
1532
1533#[derive(Debug, Deserialize)]
1534struct HttpExpectedResponse {
1535 status_code: u16,
1536 #[serde(default)]
1537 body: Option<serde_json::Value>,
1538 #[serde(default)]
1539 headers: HashMap<String, String>,
1540}
1541
1542#[derive(Debug, Deserialize)]
1543struct HttpFixture {
1544 expected_response: HttpExpectedResponse,
1545}
1546
1547#[derive(Debug, Deserialize)]
1548struct Fixture {
1549 id: String,
1550 #[serde(default)]
1551 mock_response: Option<MockResponse>,
1552 #[serde(default)]
1553 http: Option<HttpFixture>,
1554}
1555
1556impl Fixture {
1557 /// Bridge both schemas into a unified MockResponse.
1558 fn as_mock_response(&self) -> Option<MockResponse> {
1559 if let Some(mock) = &self.mock_response {
1560 return Some(MockResponse {
1561 status: mock.status,
1562 body: mock.body.clone(),
1563 stream_chunks: mock.stream_chunks.clone(),
1564 headers: mock.headers.clone(),
1565 });
1566 }
1567 if let Some(http) = &self.http {
1568 return Some(MockResponse {
1569 status: http.expected_response.status_code,
1570 body: http.expected_response.body.clone(),
1571 stream_chunks: None,
1572 headers: http.expected_response.headers.clone(),
1573 });
1574 }
1575 None
1576 }
1577}
1578
1579// ---------------------------------------------------------------------------
1580// Route table
1581// ---------------------------------------------------------------------------
1582
1583#[derive(Clone, Debug)]
1584struct MockRoute {
1585 status: u16,
1586 body: String,
1587 stream_chunks: Vec<String>,
1588 headers: Vec<(String, String)>,
1589}
1590
1591type RouteTable = Arc<HashMap<String, MockRoute>>;
1592
1593// ---------------------------------------------------------------------------
1594// Axum handler
1595// ---------------------------------------------------------------------------
1596
1597async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
1598 let path = req.uri().path().to_owned();
1599
1600 // Try exact match first
1601 if let Some(route) = routes.get(&path) {
1602 return serve_route(route);
1603 }
1604
1605 // Try prefix match: find a route that is a prefix of the request path
1606 // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
1607 for (route_path, route) in routes.iter() {
1608 if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
1609 return serve_route(route);
1610 }
1611 }
1612
1613 Response::builder()
1614 .status(StatusCode::NOT_FOUND)
1615 .body(Body::from(format!("No mock route for {path}")))
1616 .unwrap()
1617 .into_response()
1618}
1619
1620fn serve_route(route: &MockRoute) -> Response {
1621 let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1622
1623 if !route.stream_chunks.is_empty() {
1624 let mut sse = String::new();
1625 for chunk in &route.stream_chunks {
1626 sse.push_str("data: ");
1627 sse.push_str(chunk);
1628 sse.push_str("\n\n");
1629 }
1630 sse.push_str("data: [DONE]\n\n");
1631
1632 let mut builder = Response::builder()
1633 .status(status)
1634 .header("content-type", "text/event-stream")
1635 .header("cache-control", "no-cache");
1636 for (name, value) in &route.headers {
1637 builder = builder.header(name, value);
1638 }
1639 return builder.body(Body::from(sse)).unwrap().into_response();
1640 }
1641
1642 // Only set the default content-type if the fixture does not override it.
1643 // Use application/json when the body looks like JSON (starts with { or [),
1644 // otherwise fall back to text/plain to avoid clients failing JSON-decode.
1645 let has_content_type = route.headers.iter().any(|(k, _)| k.to_lowercase() == "content-type");
1646 let mut builder = Response::builder().status(status);
1647 if !has_content_type {
1648 let trimmed = route.body.trim_start();
1649 let default_ct = if trimmed.starts_with('{') || trimmed.starts_with('[') {
1650 "application/json"
1651 } else {
1652 "text/plain"
1653 };
1654 builder = builder.header("content-type", default_ct);
1655 }
1656 for (name, value) in &route.headers {
1657 // Skip content-encoding headers — the mock server returns uncompressed bodies.
1658 // Sending a content-encoding without actually encoding the body would cause
1659 // clients to fail decompression.
1660 if name.to_lowercase() == "content-encoding" {
1661 continue;
1662 }
1663 // The <<absent>> sentinel means this header must NOT be present in the
1664 // real server response — do not emit it from the mock server either.
1665 if value == "<<absent>>" {
1666 continue;
1667 }
1668 // Replace the <<uuid>> sentinel with a real UUID v4 so clients can
1669 // assert the header value matches the UUID pattern.
1670 if value == "<<uuid>>" {
1671 let uuid = format!(
1672 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
1673 rand_u32(),
1674 rand_u16(),
1675 rand_u16() & 0x0fff,
1676 (rand_u16() & 0x3fff) | 0x8000,
1677 rand_u48(),
1678 );
1679 builder = builder.header(name, uuid);
1680 continue;
1681 }
1682 builder = builder.header(name, value);
1683 }
1684 builder.body(Body::from(route.body.clone())).unwrap().into_response()
1685}
1686
1687/// Generate a pseudo-random u32 using the current time nanoseconds.
1688fn rand_u32() -> u32 {
1689 use std::time::{SystemTime, UNIX_EPOCH};
1690 let ns = SystemTime::now()
1691 .duration_since(UNIX_EPOCH)
1692 .map(|d| d.subsec_nanos())
1693 .unwrap_or(0);
1694 ns ^ (ns.wrapping_shl(13)) ^ (ns.wrapping_shr(17))
1695}
1696
1697fn rand_u16() -> u16 {
1698 (rand_u32() & 0xffff) as u16
1699}
1700
1701fn rand_u48() -> u64 {
1702 ((rand_u32() as u64) << 16) | (rand_u16() as u64)
1703}
1704
1705// ---------------------------------------------------------------------------
1706// Fixture loading
1707// ---------------------------------------------------------------------------
1708
1709fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
1710 let mut routes = HashMap::new();
1711 load_routes_recursive(fixtures_dir, &mut routes);
1712 routes
1713}
1714
1715fn load_routes_recursive(dir: &Path, routes: &mut HashMap<String, MockRoute>) {
1716 let entries = match std::fs::read_dir(dir) {
1717 Ok(e) => e,
1718 Err(err) => {
1719 eprintln!("warning: cannot read directory {}: {err}", dir.display());
1720 return;
1721 }
1722 };
1723
1724 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
1725 paths.sort();
1726
1727 for path in paths {
1728 if path.is_dir() {
1729 load_routes_recursive(&path, routes);
1730 } else if path.extension().is_some_and(|ext| ext == "json") {
1731 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1732 if filename == "schema.json" || filename.starts_with('_') {
1733 continue;
1734 }
1735 let content = match std::fs::read_to_string(&path) {
1736 Ok(c) => c,
1737 Err(err) => {
1738 eprintln!("warning: cannot read {}: {err}", path.display());
1739 continue;
1740 }
1741 };
1742 let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
1743 match serde_json::from_str(&content) {
1744 Ok(v) => v,
1745 Err(err) => {
1746 eprintln!("warning: cannot parse {}: {err}", path.display());
1747 continue;
1748 }
1749 }
1750 } else {
1751 match serde_json::from_str::<Fixture>(&content) {
1752 Ok(f) => vec![f],
1753 Err(err) => {
1754 eprintln!("warning: cannot parse {}: {err}", path.display());
1755 continue;
1756 }
1757 }
1758 };
1759
1760 for fixture in fixtures {
1761 if let Some(mock) = fixture.as_mock_response() {
1762 let route_path = format!("/fixtures/{}", fixture.id);
1763 let body = mock
1764 .body
1765 .as_ref()
1766 .map(|b| match b {
1767 // Plain strings (e.g. text/plain bodies) are stored as JSON strings in
1768 // fixtures. Return the raw value so clients receive the string itself,
1769 // not its JSON-encoded form with extra surrounding quotes.
1770 serde_json::Value::String(s) => s.clone(),
1771 other => serde_json::to_string(other).unwrap_or_default(),
1772 })
1773 .unwrap_or_default();
1774 let stream_chunks = mock
1775 .stream_chunks
1776 .unwrap_or_default()
1777 .into_iter()
1778 .map(|c| match c {
1779 serde_json::Value::String(s) => s,
1780 other => serde_json::to_string(&other).unwrap_or_default(),
1781 })
1782 .collect();
1783 let mut headers: Vec<(String, String)> =
1784 mock.headers.into_iter().collect();
1785 headers.sort_by(|a, b| a.0.cmp(&b.0));
1786 routes.insert(route_path, MockRoute { status: mock.status, body, stream_chunks, headers });
1787 }
1788 }
1789 }
1790 }
1791}
1792
1793// ---------------------------------------------------------------------------
1794// Entry point
1795// ---------------------------------------------------------------------------
1796
1797#[tokio::main]
1798async fn main() {
1799 let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
1800 let fixtures_dir = Path::new(&fixtures_dir_arg);
1801
1802 let routes = load_routes(fixtures_dir);
1803 eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
1804
1805 let route_table: RouteTable = Arc::new(routes);
1806 let app = Router::new().fallback(handle_request).with_state(route_table);
1807
1808 let listener = TcpListener::bind("127.0.0.1:0")
1809 .await
1810 .expect("mock-server: failed to bind port");
1811 let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
1812
1813 // Print the URL so the parent process can read it.
1814 println!("MOCK_SERVER_URL=http://{addr}");
1815 // Flush stdout explicitly so the parent does not block waiting.
1816 use std::io::Write;
1817 std::io::stdout().flush().expect("mock-server: failed to flush stdout");
1818
1819 // Spawn the server in the background.
1820 tokio::spawn(async move {
1821 axum::serve(listener, app).await.expect("mock-server: server error");
1822 });
1823
1824 // Block until stdin is closed — the parent process controls lifetime.
1825 let stdin = io::stdin();
1826 let mut lines = stdin.lock().lines();
1827 while lines.next().is_some() {}
1828}
1829"#
1830}
1831
1832#[allow(clippy::too_many_arguments)]
1837fn render_assertion(
1838 out: &mut String,
1839 assertion: &Assertion,
1840 result_var: &str,
1841 module: &str,
1842 dep_name: &str,
1843 is_error_context: bool,
1844 unwrapped_fields: &[(String, String)], field_resolver: &FieldResolver,
1846 result_is_tree: bool,
1847 result_is_simple: bool,
1848 result_is_vec: bool,
1849 result_is_option: bool,
1850) {
1851 let has_field = assertion.field.as_ref().is_some_and(|f| !f.is_empty());
1856 if result_is_vec && has_field && !is_error_context {
1857 let _ = writeln!(out, " for r in &{result_var} {{");
1858 render_assertion(
1859 out,
1860 assertion,
1861 "r",
1862 module,
1863 dep_name,
1864 is_error_context,
1865 unwrapped_fields,
1866 field_resolver,
1867 result_is_tree,
1868 result_is_simple,
1869 false, result_is_option,
1871 );
1872 let _ = writeln!(out, " }}");
1873 return;
1874 }
1875 if result_is_option && !is_error_context {
1878 let assertion_type = assertion.assertion_type.as_str();
1879 if !has_field && (assertion_type == "is_empty" || assertion_type == "not_empty") {
1880 let check = if assertion_type == "is_empty" {
1881 "is_none"
1882 } else {
1883 "is_some"
1884 };
1885 let _ = writeln!(
1886 out,
1887 " assert!({result_var}.{check}(), \"expected Option to be {check}\");"
1888 );
1889 return;
1890 }
1891 let _ = writeln!(
1895 out,
1896 " let r = {result_var}.as_ref().expect(\"Option<T> should be Some\");"
1897 );
1898 render_assertion(
1899 out,
1900 assertion,
1901 "r",
1902 module,
1903 dep_name,
1904 is_error_context,
1905 unwrapped_fields,
1906 field_resolver,
1907 result_is_tree,
1908 result_is_simple,
1909 result_is_vec,
1910 false, );
1912 return;
1913 }
1914 let _ = dep_name;
1915 if let Some(f) = &assertion.field {
1919 match f.as_str() {
1920 "chunks_have_content" => {
1921 match assertion.assertion_type.as_str() {
1922 "is_true" => {
1923 let _ = writeln!(
1924 out,
1925 " 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\");"
1926 );
1927 }
1928 "is_false" => {
1929 let _ = writeln!(
1930 out,
1931 " 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\");"
1932 );
1933 }
1934 _ => {
1935 let _ = writeln!(
1936 out,
1937 " // unsupported assertion type on synthetic field chunks_have_content"
1938 );
1939 }
1940 }
1941 return;
1942 }
1943 "chunks_have_embeddings" => {
1944 match assertion.assertion_type.as_str() {
1945 "is_true" => {
1946 let _ = writeln!(
1947 out,
1948 " 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\");"
1949 );
1950 }
1951 "is_false" => {
1952 let _ = writeln!(
1953 out,
1954 " 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\");"
1955 );
1956 }
1957 _ => {
1958 let _ = writeln!(
1959 out,
1960 " // unsupported assertion type on synthetic field chunks_have_embeddings"
1961 );
1962 }
1963 }
1964 return;
1965 }
1966 "embeddings" => {
1970 let embed_list = result_var.to_string();
1973 match assertion.assertion_type.as_str() {
1974 "count_equals" => {
1975 if let Some(val) = &assertion.value {
1976 if let Some(n) = val.as_u64() {
1977 let _ = writeln!(
1978 out,
1979 " assert_eq!({embed_list}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {embed_list}.len());"
1980 );
1981 }
1982 }
1983 }
1984 "count_min" => {
1985 if let Some(val) = &assertion.value {
1986 if let Some(n) = val.as_u64() {
1987 if n <= 1 {
1988 let _ =
1989 writeln!(out, " assert!(!{embed_list}.is_empty(), \"expected >= {n}\");");
1990 } else {
1991 let _ = writeln!(
1992 out,
1993 " assert!({embed_list}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {embed_list}.len());"
1994 );
1995 }
1996 }
1997 }
1998 }
1999 "not_empty" => {
2000 let _ = writeln!(
2001 out,
2002 " assert!(!{embed_list}.is_empty(), \"expected non-empty embeddings\");"
2003 );
2004 }
2005 "is_empty" => {
2006 let _ = writeln!(
2007 out,
2008 " assert!({embed_list}.is_empty(), \"expected empty embeddings\");"
2009 );
2010 }
2011 _ => {
2012 let _ = writeln!(
2013 out,
2014 " // skipped: unsupported assertion type on synthetic field 'embeddings'"
2015 );
2016 }
2017 }
2018 return;
2019 }
2020 "embedding_dimensions" => {
2021 let embed_list = result_var;
2022 let expr = format!("{embed_list}.first().map_or(0, |e| e.len())");
2023 match assertion.assertion_type.as_str() {
2024 "equals" => {
2025 if let Some(val) = &assertion.value {
2026 let lit = numeric_literal(val);
2027 let _ = writeln!(
2028 out,
2029 " assert_eq!({expr}, {lit} as usize, \"equals assertion failed\");"
2030 );
2031 }
2032 }
2033 "greater_than" => {
2034 if let Some(val) = &assertion.value {
2035 let lit = numeric_literal(val);
2036 let _ = writeln!(out, " assert!({expr} > {lit} as usize, \"expected > {lit}\");");
2037 }
2038 }
2039 _ => {
2040 let _ = writeln!(
2041 out,
2042 " // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
2043 );
2044 }
2045 }
2046 return;
2047 }
2048 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
2049 let embed_list = result_var;
2050 let pred = match f.as_str() {
2051 "embeddings_valid" => {
2052 format!("{embed_list}.iter().all(|e| !e.is_empty())")
2053 }
2054 "embeddings_finite" => {
2055 format!("{embed_list}.iter().all(|e| e.iter().all(|v| v.is_finite()))")
2056 }
2057 "embeddings_non_zero" => {
2058 format!("{embed_list}.iter().all(|e| e.iter().any(|v| *v != 0.0_f32))")
2059 }
2060 "embeddings_normalized" => {
2061 format!(
2062 "{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 }})"
2063 )
2064 }
2065 _ => unreachable!(),
2066 };
2067 match assertion.assertion_type.as_str() {
2068 "is_true" => {
2069 let _ = writeln!(out, " assert!({pred}, \"expected true\");");
2070 }
2071 "is_false" => {
2072 let _ = writeln!(out, " assert!(!({pred}), \"expected false\");");
2073 }
2074 _ => {
2075 let _ = writeln!(
2076 out,
2077 " // skipped: unsupported assertion type on synthetic field '{f}'"
2078 );
2079 }
2080 }
2081 return;
2082 }
2083 "keywords" => {
2087 let accessor = format!("{result_var}.extracted_keywords");
2088 match assertion.assertion_type.as_str() {
2089 "not_empty" => {
2090 let _ = writeln!(
2091 out,
2092 " assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected keywords to be present and non-empty\");"
2093 );
2094 }
2095 "is_empty" => {
2096 let _ = writeln!(
2097 out,
2098 " assert!({accessor}.as_ref().is_none_or(|v| v.is_empty()), \"expected keywords to be empty or absent\");"
2099 );
2100 }
2101 "count_min" => {
2102 if let Some(val) = &assertion.value {
2103 if let Some(n) = val.as_u64() {
2104 if n <= 1 {
2105 let _ = writeln!(
2106 out,
2107 " assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected >= {n}\");"
2108 );
2109 } else {
2110 let _ = writeln!(
2111 out,
2112 " assert!({accessor}.as_ref().is_some_and(|v| v.len() >= {n}), \"expected at least {n} keywords\");"
2113 );
2114 }
2115 }
2116 }
2117 }
2118 "count_equals" => {
2119 if let Some(val) = &assertion.value {
2120 if let Some(n) = val.as_u64() {
2121 let _ = writeln!(
2122 out,
2123 " assert!({accessor}.as_ref().is_some_and(|v| v.len() == {n}), \"expected exactly {n} keywords\");"
2124 );
2125 }
2126 }
2127 }
2128 _ => {
2129 let _ = writeln!(
2130 out,
2131 " // skipped: unsupported assertion type on synthetic field 'keywords'"
2132 );
2133 }
2134 }
2135 return;
2136 }
2137 "keywords_count" => {
2138 let expr = format!("{result_var}.extracted_keywords.as_ref().map_or(0, |v| v.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 "less_than_or_equal" => {
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 "greater_than_or_equal" => {
2156 if let Some(val) = &assertion.value {
2157 let lit = numeric_literal(val);
2158 let _ = writeln!(out, " assert!({expr} >= {lit} as usize, \"expected >= {lit}\");");
2159 }
2160 }
2161 "greater_than" => {
2162 if let Some(val) = &assertion.value {
2163 let lit = numeric_literal(val);
2164 let _ = writeln!(out, " assert!({expr} > {lit} as usize, \"expected > {lit}\");");
2165 }
2166 }
2167 "less_than" => {
2168 if let Some(val) = &assertion.value {
2169 let lit = numeric_literal(val);
2170 let _ = writeln!(out, " assert!({expr} < {lit} as usize, \"expected < {lit}\");");
2171 }
2172 }
2173 _ => {
2174 let _ = writeln!(
2175 out,
2176 " // skipped: unsupported assertion type on synthetic field 'keywords_count'"
2177 );
2178 }
2179 }
2180 return;
2181 }
2182 _ => {}
2183 }
2184 }
2185
2186 if let Some(f) = &assertion.field {
2188 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
2189 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
2190 return;
2191 }
2192 }
2193
2194 let field_access = match &assertion.field {
2202 Some(f) if !f.is_empty() => {
2203 if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
2204 local_var.clone()
2205 } else if result_is_simple {
2206 result_var.to_string()
2209 } else if f == result_var {
2210 result_var.to_string()
2213 } else if result_is_tree {
2214 tree_field_access_expr(f, result_var, module)
2217 } else {
2218 field_resolver.accessor(f, "rust", result_var)
2219 }
2220 }
2221 _ => result_var.to_string(),
2222 };
2223
2224 let is_unwrapped = assertion
2226 .field
2227 .as_ref()
2228 .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
2229
2230 match assertion.assertion_type.as_str() {
2231 "error" => {
2232 let _ = writeln!(out, " assert!({result_var}.is_err(), \"expected call to fail\");");
2233 if let Some(serde_json::Value::String(msg)) = &assertion.value {
2234 let escaped = escape_rust(msg);
2235 let _ = writeln!(
2236 out,
2237 " assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
2238 );
2239 }
2240 }
2241 "not_error" => {
2242 }
2244 "equals" => {
2245 if let Some(val) = &assertion.value {
2246 let expected = value_to_rust_string(val);
2247 if is_error_context {
2248 return;
2249 }
2250 if val.is_string() {
2253 let is_opt_str_not_unwrapped = assertion.field.as_ref().is_some_and(|f| {
2258 let resolved = field_resolver.resolve(f);
2259 let is_opt = field_resolver.is_optional(resolved);
2260 let is_arr = field_resolver.is_array(resolved);
2261 is_opt && !is_arr && !is_unwrapped
2262 });
2263 let field_expr = if is_opt_str_not_unwrapped {
2264 format!("{field_access}.as_deref().unwrap_or(\"\").trim()")
2265 } else {
2266 format!("{field_access}.trim()")
2267 };
2268 let _ = writeln!(
2269 out,
2270 " assert_eq!({field_expr}, {expected}, \"equals assertion failed\");"
2271 );
2272 } else if val.is_boolean() {
2273 if val.as_bool() == Some(true) {
2275 let _ = writeln!(out, " assert!({field_access}, \"equals assertion failed\");");
2276 } else {
2277 let _ = writeln!(out, " assert!(!{field_access}, \"equals assertion failed\");");
2278 }
2279 } else {
2280 let is_opt = assertion.field.as_ref().is_some_and(|f| {
2282 let resolved = field_resolver.resolve(f);
2283 field_resolver.is_optional(resolved)
2284 });
2285 if is_opt
2286 && !unwrapped_fields
2287 .iter()
2288 .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
2289 {
2290 let _ = writeln!(
2291 out,
2292 " assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
2293 );
2294 } else {
2295 let _ = writeln!(
2296 out,
2297 " assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
2298 );
2299 }
2300 }
2301 }
2302 }
2303 "contains" => {
2304 if let Some(val) = &assertion.value {
2305 let expected = value_to_rust_string(val);
2306 let line = format!(
2307 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
2308 );
2309 let _ = writeln!(out, "{line}");
2310 }
2311 }
2312 "contains_all" => {
2313 if let Some(values) = &assertion.values {
2314 for val in values {
2315 let expected = value_to_rust_string(val);
2316 let line = format!(
2317 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
2318 );
2319 let _ = writeln!(out, "{line}");
2320 }
2321 }
2322 }
2323 "not_contains" => {
2324 if let Some(val) = &assertion.value {
2325 let expected = value_to_rust_string(val);
2326 let line = format!(
2327 " assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
2328 );
2329 let _ = writeln!(out, "{line}");
2330 }
2331 }
2332 "not_empty" => {
2333 if let Some(f) = &assertion.field {
2334 let resolved = field_resolver.resolve(f);
2335 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2336 let is_arr = field_resolver.is_array(resolved);
2337 if is_opt && is_arr {
2338 let accessor = field_resolver.accessor(f, "rust", result_var);
2340 let _ = writeln!(
2341 out,
2342 " assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected {f} to be present and non-empty\");"
2343 );
2344 } else if is_opt {
2345 let accessor = field_resolver.accessor(f, "rust", result_var);
2347 let _ = writeln!(
2348 out,
2349 " assert!({accessor}.is_some(), \"expected {f} to be present\");"
2350 );
2351 } else {
2352 let _ = writeln!(
2353 out,
2354 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
2355 );
2356 }
2357 } else if result_is_option {
2358 let _ = writeln!(
2360 out,
2361 " assert!({field_access}.is_some(), \"expected non-empty value\");"
2362 );
2363 } else {
2364 let _ = writeln!(
2366 out,
2367 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
2368 );
2369 }
2370 }
2371 "is_empty" => {
2372 if let Some(f) = &assertion.field {
2373 let resolved = field_resolver.resolve(f);
2374 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2375 let is_arr = field_resolver.is_array(resolved);
2376 if is_opt && is_arr {
2377 let accessor = field_resolver.accessor(f, "rust", result_var);
2379 let _ = writeln!(
2380 out,
2381 " assert!({accessor}.as_ref().is_none_or(|v| v.is_empty()), \"expected {f} to be empty or absent\");"
2382 );
2383 } else if is_opt {
2384 let accessor = field_resolver.accessor(f, "rust", result_var);
2385 let _ = writeln!(out, " assert!({accessor}.is_none(), \"expected {f} to be absent\");");
2386 } else {
2387 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
2388 }
2389 } else {
2390 let _ = writeln!(out, " assert!({field_access}.is_none(), \"expected empty value\");");
2391 }
2392 }
2393 "contains_any" => {
2394 if let Some(values) = &assertion.values {
2395 let checks: Vec<String> = values
2396 .iter()
2397 .map(|v| {
2398 let expected = value_to_rust_string(v);
2399 format!("{field_access}.contains({expected})")
2400 })
2401 .collect();
2402 let joined = checks.join(" || ");
2403 let _ = writeln!(
2404 out,
2405 " assert!({joined}, \"expected to contain at least one of the specified values\");"
2406 );
2407 }
2408 }
2409 "greater_than" => {
2410 if let Some(val) = &assertion.value {
2411 if val.as_f64().is_some_and(|n| n < 0.0) {
2413 let _ = writeln!(
2414 out,
2415 " // skipped: greater_than with negative value is always true for unsigned types"
2416 );
2417 } else if val.as_u64() == Some(0) {
2418 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2420 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected > 0\");");
2421 } else {
2422 let lit = numeric_literal(val);
2423 let _ = writeln!(out, " assert!({field_access} > {lit}, \"expected > {lit}\");");
2424 }
2425 }
2426 }
2427 "less_than" => {
2428 if let Some(val) = &assertion.value {
2429 let lit = numeric_literal(val);
2430 let _ = writeln!(out, " assert!({field_access} < {lit}, \"expected < {lit}\");");
2431 }
2432 }
2433 "greater_than_or_equal" => {
2434 if let Some(val) = &assertion.value {
2435 let lit = numeric_literal(val);
2436 let is_opt_numeric = assertion.field.as_ref().is_some_and(|f| {
2439 let resolved = field_resolver.resolve(f);
2440 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2441 let is_arr = field_resolver.is_array(resolved);
2442 is_opt && !is_arr
2443 });
2444 if val.as_u64() == Some(1) && field_access.ends_with(".len()") {
2445 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2449 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= 1\");");
2450 } else if is_opt_numeric {
2451 let _ = writeln!(
2453 out,
2454 " assert!({field_access}.unwrap_or(0) >= {lit}, \"expected >= {lit}\");"
2455 );
2456 } else {
2457 let _ = writeln!(out, " assert!({field_access} >= {lit}, \"expected >= {lit}\");");
2458 }
2459 }
2460 }
2461 "less_than_or_equal" => {
2462 if let Some(val) = &assertion.value {
2463 let lit = numeric_literal(val);
2464 let _ = writeln!(out, " assert!({field_access} <= {lit}, \"expected <= {lit}\");");
2465 }
2466 }
2467 "starts_with" => {
2468 if let Some(val) = &assertion.value {
2469 let expected = value_to_rust_string(val);
2470 let _ = writeln!(
2471 out,
2472 " assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
2473 );
2474 }
2475 }
2476 "ends_with" => {
2477 if let Some(val) = &assertion.value {
2478 let expected = value_to_rust_string(val);
2479 let _ = writeln!(
2480 out,
2481 " assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
2482 );
2483 }
2484 }
2485 "min_length" => {
2486 if let Some(val) = &assertion.value {
2487 if let Some(n) = val.as_u64() {
2488 let _ = writeln!(
2489 out,
2490 " assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
2491 );
2492 }
2493 }
2494 }
2495 "max_length" => {
2496 if let Some(val) = &assertion.value {
2497 if let Some(n) = val.as_u64() {
2498 let _ = writeln!(
2499 out,
2500 " assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
2501 );
2502 }
2503 }
2504 }
2505 "count_min" => {
2506 if let Some(val) = &assertion.value {
2507 if let Some(n) = val.as_u64() {
2508 let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
2509 let resolved = field_resolver.resolve(f);
2510 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2511 let is_arr = field_resolver.is_array(resolved);
2512 is_opt && is_arr
2513 });
2514 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2515 if opt_arr_field {
2516 if n <= 1 {
2518 let _ = writeln!(
2519 out,
2520 " assert!({base}.as_ref().is_some_and(|v| !v.is_empty()), \"expected >= {n}\");"
2521 );
2522 } else {
2523 let _ = writeln!(
2524 out,
2525 " assert!({base}.as_ref().is_some_and(|v| v.len() >= {n}), \"expected at least {n} elements\");"
2526 );
2527 }
2528 } else if n <= 1 {
2529 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= {n}\");");
2530 } else {
2531 let _ = writeln!(
2532 out,
2533 " assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
2534 );
2535 }
2536 }
2537 }
2538 }
2539 "count_equals" => {
2540 if let Some(val) = &assertion.value {
2541 if let Some(n) = val.as_u64() {
2542 let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
2543 let resolved = field_resolver.resolve(f);
2544 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2545 let is_arr = field_resolver.is_array(resolved);
2546 is_opt && is_arr
2547 });
2548 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2549 if opt_arr_field {
2550 let _ = writeln!(
2551 out,
2552 " assert!({base}.as_ref().is_some_and(|v| v.len() == {n}), \"expected exactly {n} elements\");"
2553 );
2554 } else {
2555 let _ = writeln!(
2556 out,
2557 " assert_eq!({field_access}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {field_access}.len());"
2558 );
2559 }
2560 }
2561 }
2562 }
2563 "is_true" => {
2564 let _ = writeln!(out, " assert!({field_access}, \"expected true\");");
2565 }
2566 "is_false" => {
2567 let _ = writeln!(out, " assert!(!{field_access}, \"expected false\");");
2568 }
2569 "method_result" => {
2570 if let Some(method_name) = &assertion.method {
2571 let call_expr = if result_is_tree {
2575 build_tree_call_expr(field_access.as_str(), method_name, assertion.args.as_ref(), module)
2576 } else if let Some(args) = &assertion.args {
2577 let arg_lit = json_to_rust_literal(args, "");
2578 format!("{field_access}.{method_name}({arg_lit})")
2579 } else {
2580 format!("{field_access}.{method_name}()")
2581 };
2582
2583 let returns_numeric = result_is_tree && is_tree_numeric_method(method_name);
2586
2587 let check = assertion.check.as_deref().unwrap_or("is_true");
2588 match check {
2589 "equals" => {
2590 if let Some(val) = &assertion.value {
2591 if val.is_boolean() {
2592 if val.as_bool() == Some(true) {
2593 let _ = writeln!(
2594 out,
2595 " assert!({call_expr}, \"method_result equals assertion failed\");"
2596 );
2597 } else {
2598 let _ = writeln!(
2599 out,
2600 " assert!(!{call_expr}, \"method_result equals assertion failed\");"
2601 );
2602 }
2603 } else {
2604 let expected = value_to_rust_string(val);
2605 let _ = writeln!(
2606 out,
2607 " assert_eq!({call_expr}, {expected}, \"method_result equals assertion failed\");"
2608 );
2609 }
2610 }
2611 }
2612 "is_true" => {
2613 let _ = writeln!(
2614 out,
2615 " assert!({call_expr}, \"method_result is_true assertion failed\");"
2616 );
2617 }
2618 "is_false" => {
2619 let _ = writeln!(
2620 out,
2621 " assert!(!{call_expr}, \"method_result is_false assertion failed\");"
2622 );
2623 }
2624 "greater_than_or_equal" => {
2625 if let Some(val) = &assertion.value {
2626 let lit = numeric_literal(val);
2627 if returns_numeric {
2628 let _ = writeln!(out, " assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
2630 } else if val.as_u64() == Some(1) {
2631 let _ = writeln!(out, " assert!(!{call_expr}.is_empty(), \"expected >= 1\");");
2633 } else {
2634 let _ = writeln!(out, " assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
2635 }
2636 }
2637 }
2638 "count_min" => {
2639 if let Some(val) = &assertion.value {
2640 let n = val.as_u64().unwrap_or(0);
2641 if n <= 1 {
2642 let _ = writeln!(out, " assert!(!{call_expr}.is_empty(), \"expected >= {n}\");");
2643 } else {
2644 let _ = writeln!(
2645 out,
2646 " assert!({call_expr}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {call_expr}.len());"
2647 );
2648 }
2649 }
2650 }
2651 "is_error" => {
2652 let raw_call = call_expr.strip_suffix(".unwrap()").unwrap_or(&call_expr);
2654 let _ = writeln!(
2655 out,
2656 " assert!({raw_call}.is_err(), \"expected method to return error\");"
2657 );
2658 }
2659 "contains" => {
2660 if let Some(val) = &assertion.value {
2661 let expected = value_to_rust_string(val);
2662 let _ = writeln!(
2663 out,
2664 " assert!({call_expr}.contains({expected}), \"expected result to contain {{}}\", {expected});"
2665 );
2666 }
2667 }
2668 "not_empty" => {
2669 let _ = writeln!(
2670 out,
2671 " assert!(!{call_expr}.is_empty(), \"expected non-empty result\");"
2672 );
2673 }
2674 "is_empty" => {
2675 let _ = writeln!(out, " assert!({call_expr}.is_empty(), \"expected empty result\");");
2676 }
2677 other_check => {
2678 panic!("Rust e2e generator: unsupported method_result check type: {other_check}");
2679 }
2680 }
2681 } else {
2682 panic!("Rust e2e generator: method_result assertion missing 'method' field");
2683 }
2684 }
2685 other => {
2686 panic!("Rust e2e generator: unsupported assertion type: {other}");
2687 }
2688 }
2689}
2690
2691fn tree_field_access_expr(field: &str, result_var: &str, module: &str) -> String {
2699 match field {
2700 "root_child_count" => format!("{result_var}.root_node().child_count()"),
2701 "root_node_type" => format!("{result_var}.root_node().kind()"),
2702 "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
2703 "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{result_var})"),
2704 "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{result_var})"),
2705 "tree_to_sexp" => format!("{module}::tree_to_sexp(&{result_var})"),
2706 other => format!("{result_var}.{other}"),
2709 }
2710}
2711
2712fn build_tree_call_expr(
2719 field_access: &str,
2720 method_name: &str,
2721 args: Option<&serde_json::Value>,
2722 module: &str,
2723) -> String {
2724 match method_name {
2725 "root_child_count" => format!("{field_access}.root_node().child_count()"),
2726 "root_node_type" => format!("{field_access}.root_node().kind()"),
2727 "named_children_count" => format!("{field_access}.root_node().named_child_count()"),
2728 "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{field_access})"),
2729 "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{field_access})"),
2730 "tree_to_sexp" => format!("{module}::tree_to_sexp(&{field_access})"),
2731 "contains_node_type" => {
2732 let node_type = args
2733 .and_then(|a| a.get("node_type"))
2734 .and_then(|v| v.as_str())
2735 .unwrap_or("");
2736 format!("{module}::tree_contains_node_type(&{field_access}, \"{node_type}\")")
2737 }
2738 "find_nodes_by_type" => {
2739 let node_type = args
2740 .and_then(|a| a.get("node_type"))
2741 .and_then(|v| v.as_str())
2742 .unwrap_or("");
2743 format!("{module}::find_nodes_by_type(&{field_access}, \"{node_type}\")")
2744 }
2745 "run_query" => {
2746 let query_source = args
2747 .and_then(|a| a.get("query_source"))
2748 .and_then(|v| v.as_str())
2749 .unwrap_or("");
2750 let language = args
2751 .and_then(|a| a.get("language"))
2752 .and_then(|v| v.as_str())
2753 .unwrap_or("");
2754 format!(
2757 "{module}::run_query(&{field_access}, \"{language}\", r#\"{query_source}\"#, source.as_bytes()).unwrap()"
2758 )
2759 }
2760 _ => {
2762 if let Some(args) = args {
2763 let arg_lit = json_to_rust_literal(args, "");
2764 format!("{field_access}.{method_name}({arg_lit})")
2765 } else {
2766 format!("{field_access}.{method_name}()")
2767 }
2768 }
2769 }
2770}
2771
2772fn is_tree_numeric_method(method_name: &str) -> bool {
2776 matches!(
2777 method_name,
2778 "root_child_count" | "named_children_count" | "error_count" | "tree_error_count"
2779 )
2780}
2781
2782fn numeric_literal(value: &serde_json::Value) -> String {
2788 if let Some(n) = value.as_f64() {
2789 if n.fract() == 0.0 {
2790 return format!("{}", n as i64);
2793 }
2794 return format!("{n}_f64");
2795 }
2796 value.to_string()
2798}
2799
2800fn value_to_rust_string(value: &serde_json::Value) -> String {
2801 match value {
2802 serde_json::Value::String(s) => rust_raw_string(s),
2803 serde_json::Value::Bool(b) => format!("{b}"),
2804 serde_json::Value::Number(n) => n.to_string(),
2805 other => {
2806 let s = other.to_string();
2807 format!("\"{s}\"")
2808 }
2809 }
2810}
2811
2812fn resolve_visitor_trait(module: &str) -> String {
2818 if module.contains("html_to_markdown") {
2820 "HtmlVisitor".to_string()
2821 } else {
2822 "Visitor".to_string()
2824 }
2825}
2826
2827fn emit_rust_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
2835 let params = match method_name {
2839 "visit_link" => "_: &NodeContext, _: &str, _: &str, _: &str",
2840 "visit_image" => "_: &NodeContext, _: &str, _: &str, _: &str",
2841 "visit_heading" => "_: &NodeContext, _: u8, _: &str, _: Option<&str>",
2842 "visit_code_block" => "_: &NodeContext, _: Option<&str>, _: &str",
2843 "visit_code_inline"
2844 | "visit_strong"
2845 | "visit_emphasis"
2846 | "visit_strikethrough"
2847 | "visit_underline"
2848 | "visit_subscript"
2849 | "visit_superscript"
2850 | "visit_mark"
2851 | "visit_button"
2852 | "visit_summary"
2853 | "visit_figcaption"
2854 | "visit_definition_term"
2855 | "visit_definition_description" => "_: &NodeContext, _: &str",
2856 "visit_text" => "_: &NodeContext, _: &str",
2857 "visit_list_item" => "_: &NodeContext, _: bool, _: &str, _: &str",
2858 "visit_blockquote" => "_: &NodeContext, _: &str, _: u32",
2859 "visit_table_row" => "_: &NodeContext, _: &[String], _: bool",
2860 "visit_custom_element" => "_: &NodeContext, _: &str, _: &str",
2861 "visit_form" => "_: &NodeContext, _: &str, _: &str",
2862 "visit_input" => "_: &NodeContext, _: &str, _: &str, _: &str",
2863 "visit_audio" | "visit_video" | "visit_iframe" => "_: &NodeContext, _: &str",
2864 "visit_details" => "_: &NodeContext, _: bool",
2865 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
2866 "_: &NodeContext, _: &str"
2867 }
2868 "visit_list_start" => "_: &NodeContext, _: bool",
2869 "visit_list_end" => "_: &NodeContext, _: bool, _: &str",
2870 _ => "_: &NodeContext",
2871 };
2872
2873 let _ = writeln!(out, " fn {method_name}(&mut self, {params}) -> VisitResult {{");
2874 match action {
2875 CallbackAction::Skip => {
2876 let _ = writeln!(out, " VisitResult::Skip");
2877 }
2878 CallbackAction::Continue => {
2879 let _ = writeln!(out, " VisitResult::Continue");
2880 }
2881 CallbackAction::PreserveHtml => {
2882 let _ = writeln!(out, " VisitResult::PreserveHtml");
2883 }
2884 CallbackAction::Custom { output } => {
2885 let escaped = escape_rust(output);
2886 let _ = writeln!(out, " VisitResult::Custom(\"{escaped}\".to_string())");
2887 }
2888 CallbackAction::CustomTemplate { template } => {
2889 let escaped = escape_rust(template);
2890 let _ = writeln!(out, " VisitResult::Custom(format!(\"{escaped}\"))");
2891 }
2892 }
2893 let _ = writeln!(out, " }}");
2894}