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, Fixture, FixtureGroup};
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
46 .iter()
47 .flat_map(|g| g.fixtures.iter())
48 .any(|f| !is_skipped(f, "rust") && f.needs_mock_server());
49
50 let any_async_call = std::iter::once(&e2e_config.call)
52 .chain(e2e_config.calls.values())
53 .any(|c| c.r#async);
54 let needs_tokio = needs_mock_server || any_async_call;
55
56 let crate_version = resolve_crate_version(e2e_config);
57 files.push(GeneratedFile {
58 path: output_base.join("Cargo.toml"),
59 content: render_cargo_toml(
60 &crate_name,
61 &dep_name,
62 &crate_path,
63 needs_serde_json,
64 needs_mock_server,
65 needs_tokio,
66 e2e_config.dep_mode,
67 crate_version.as_deref(),
68 &alef_config.crate_config.features,
69 ),
70 generated_header: true,
71 });
72
73 if needs_mock_server {
75 files.push(GeneratedFile {
76 path: output_base.join("tests").join("mock_server.rs"),
77 content: render_mock_server_module(),
78 generated_header: true,
79 });
80 files.push(GeneratedFile {
82 path: output_base.join("src").join("main.rs"),
83 content: render_mock_server_binary(),
84 generated_header: true,
85 });
86 }
87
88 for group in groups {
90 let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
91
92 if fixtures.is_empty() {
93 continue;
94 }
95
96 let filename = format!("{}_test.rs", sanitize_filename(&group.category));
97 let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name, needs_mock_server);
98
99 files.push(GeneratedFile {
100 path: output_base.join("tests").join(filename),
101 content,
102 generated_header: true,
103 });
104 }
105
106 Ok(files)
107 }
108
109 fn language_name(&self) -> &'static str {
110 "rust"
111 }
112}
113
114fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
119 alef_config.crate_config.name.clone()
123}
124
125fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
126 e2e_config
127 .resolve_package("rust")
128 .and_then(|p| p.path.clone())
129 .unwrap_or_else(|| format!("../../crates/{crate_name}"))
130}
131
132fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
133 e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
134}
135
136fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
137 call_config
138 .overrides
139 .get("rust")
140 .and_then(|o| o.function.clone())
141 .unwrap_or_else(|| call_config.function.clone())
142}
143
144fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
145 resolve_module_for_call(&e2e_config.call, dep_name)
146}
147
148fn resolve_module_for_call(call_config: &crate::config::CallConfig, dep_name: &str) -> String {
149 let overrides = call_config.overrides.get("rust");
152 overrides
153 .and_then(|o| o.crate_name.clone())
154 .or_else(|| overrides.and_then(|o| o.module.clone()))
155 .unwrap_or_else(|| dep_name.to_string())
156}
157
158fn is_skipped(fixture: &Fixture, language: &str) -> bool {
159 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
160}
161
162#[allow(clippy::too_many_arguments)]
167pub fn render_cargo_toml(
168 crate_name: &str,
169 dep_name: &str,
170 crate_path: &str,
171 needs_serde_json: bool,
172 needs_mock_server: bool,
173 needs_tokio: bool,
174 dep_mode: crate::config::DependencyMode,
175 version: Option<&str>,
176 features: &[String],
177) -> String {
178 let e2e_name = format!("{dep_name}-e2e-rust");
179 let effective_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
183 let features_str = if effective_features.is_empty() {
184 String::new()
185 } else {
186 format!(", default-features = false, features = {:?}", effective_features)
187 };
188 let dep_spec = match dep_mode {
189 crate::config::DependencyMode::Registry => {
190 let ver = version.unwrap_or("0.1.0");
191 if crate_name != dep_name {
192 format!("{dep_name} = {{ package = \"{crate_name}\", version = \"{ver}\"{features_str} }}")
193 } else if effective_features.is_empty() {
194 format!("{dep_name} = \"{ver}\"")
195 } else {
196 format!("{dep_name} = {{ version = \"{ver}\"{features_str} }}")
197 }
198 }
199 crate::config::DependencyMode::Local => {
200 if crate_name != dep_name {
201 format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\"{features_str} }}")
202 } else if effective_features.is_empty() {
203 format!("{dep_name} = {{ path = \"{crate_path}\" }}")
204 } else {
205 format!("{dep_name} = {{ path = \"{crate_path}\"{features_str} }}")
206 }
207 }
208 };
209 let serde_line = if needs_serde_json { "\nserde_json = \"1\"" } else { "" };
210 let mock_lines = if needs_mock_server {
216 format!(
217 "\naxum = \"{axum}\"\ntokio-stream = \"{tokio_stream}\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\nwalkdir = \"{walkdir}\"",
218 axum = tv::cargo::AXUM,
219 tokio_stream = tv::cargo::TOKIO_STREAM,
220 walkdir = tv::cargo::WALKDIR,
221 )
222 } else {
223 String::new()
224 };
225 let mut machete_ignored: Vec<&str> = Vec::new();
226 if needs_serde_json {
227 machete_ignored.push("\"serde_json\"");
228 }
229 if needs_mock_server {
230 machete_ignored.push("\"axum\"");
231 machete_ignored.push("\"tokio-stream\"");
232 machete_ignored.push("\"serde\"");
233 machete_ignored.push("\"walkdir\"");
234 }
235 let machete_section = if machete_ignored.is_empty() {
236 String::new()
237 } else {
238 format!(
239 "\n[package.metadata.cargo-machete]\nignored = [{}]\n",
240 machete_ignored.join(", ")
241 )
242 };
243 let tokio_line = if needs_tokio {
244 "\ntokio = { version = \"1\", features = [\"full\"] }"
245 } else {
246 ""
247 };
248 let bin_section = if needs_mock_server {
249 "\n[[bin]]\nname = \"mock-server\"\npath = \"src/main.rs\"\n"
250 } else {
251 ""
252 };
253 let header = hash::header(CommentStyle::Hash);
254 format!(
255 r#"{header}
256[package]
257name = "{e2e_name}"
258version = "0.1.0"
259edition = "2021"
260license = "MIT"
261publish = false
262{bin_section}
263[dependencies]
264{dep_spec}{serde_line}{mock_lines}{tokio_line}
265{machete_section}"#
266 )
267}
268
269fn render_test_file(
270 category: &str,
271 fixtures: &[&Fixture],
272 e2e_config: &E2eConfig,
273 dep_name: &str,
274 needs_mock_server: bool,
275) -> String {
276 let mut out = String::new();
277 out.push_str(&hash::header(CommentStyle::DoubleSlash));
278 let _ = writeln!(out, "//! E2e tests for category: {category}");
279 let _ = writeln!(out);
280
281 let module = resolve_module(e2e_config, dep_name);
282 let field_resolver = FieldResolver::new(
283 &e2e_config.fields,
284 &e2e_config.fields_optional,
285 &e2e_config.result_fields,
286 &e2e_config.fields_array,
287 );
288
289 let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
293 for fixture in fixtures.iter() {
294 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
295 let fn_name = resolve_function_name_for_call(call_config);
296 let mod_name = resolve_module_for_call(call_config, dep_name);
297 imported.insert((mod_name, fn_name));
298 }
299 let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
301 for (mod_name, fn_name) in &imported {
302 by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
303 }
304 for (mod_name, fns) in &by_module {
305 if fns.len() == 1 {
306 let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
307 } else {
308 let joined = fns.join(", ");
309 let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
310 }
311 }
312
313 let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
315 if has_handle_args {
316 let _ = writeln!(out, "use {module}::CrawlConfig;");
317 }
318 for arg in &e2e_config.call.args {
319 if arg.arg_type == "handle" {
320 use heck::ToSnakeCase;
321 let constructor_name = format!("create_{}", arg.name.to_snake_case());
322 let _ = writeln!(out, "use {module}::{constructor_name};");
323 }
324 }
325
326 let file_needs_mock = needs_mock_server && fixtures.iter().any(|f| f.needs_mock_server());
328 if file_needs_mock {
329 let _ = writeln!(out, "mod mock_server;");
330 let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
331 }
332
333 let _ = writeln!(out);
334
335 for fixture in fixtures {
336 render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
337 let _ = writeln!(out);
338 }
339
340 if !out.ends_with('\n') {
341 out.push('\n');
342 }
343 out
344}
345
346fn render_test_function(
347 out: &mut String,
348 fixture: &Fixture,
349 e2e_config: &E2eConfig,
350 dep_name: &str,
351 field_resolver: &FieldResolver,
352) {
353 let fn_name = sanitize_ident(&fixture.id);
354 let description = &fixture.description;
355 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
356 let function_name = resolve_function_name_for_call(call_config);
357 let module = resolve_module_for_call(call_config, dep_name);
358 let result_var = &call_config.result_var;
359 let has_mock = fixture.needs_mock_server();
360
361 let is_async = call_config.r#async || has_mock;
363 if is_async {
364 let _ = writeln!(out, "#[tokio::test]");
365 let _ = writeln!(out, "async fn test_{fn_name}() {{");
366 } else {
367 let _ = writeln!(out, "#[test]");
368 let _ = writeln!(out, "fn test_{fn_name}() {{");
369 }
370 let _ = writeln!(out, " // {description}");
371
372 if has_mock {
375 render_mock_server_setup(out, fixture, e2e_config);
376 }
377
378 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
380
381 let mut arg_exprs: Vec<String> = Vec::new();
383 for arg in &call_config.args {
384 let value = resolve_field(&fixture.input, &arg.field);
385 let var_name = &arg.name;
386 let (bindings, expr) = render_rust_arg(
387 var_name,
388 value,
389 &arg.arg_type,
390 arg.optional,
391 &module,
392 &fixture.id,
393 if has_mock {
394 Some("mock_server.url.as_str()")
395 } else {
396 None
397 },
398 );
399 for binding in &bindings {
400 let _ = writeln!(out, " {binding}");
401 }
402 arg_exprs.push(expr);
403 }
404
405 if let Some(visitor_spec) = &fixture.visitor {
407 let _ = writeln!(out, " struct _TestVisitor;");
408 let _ = writeln!(out, " impl {} for _TestVisitor {{", resolve_visitor_trait(&module));
409 for (method_name, action) in &visitor_spec.callbacks {
410 emit_rust_visitor_method(out, method_name, action);
411 }
412 let _ = writeln!(out, " }}");
413 let _ = writeln!(
414 out,
415 " let visitor = std::rc::Rc::new(std::cell::RefCell::new(_TestVisitor));"
416 );
417 arg_exprs.push("Some(visitor)".to_string());
418 }
419
420 let args_str = arg_exprs.join(", ");
421
422 let await_suffix = if is_async { ".await" } else { "" };
423
424 let result_is_tree = call_config.result_var == "tree";
425
426 if has_error_assertion {
427 let _ = writeln!(out, " let {result_var} = {function_name}({args_str}){await_suffix};");
428 for assertion in &fixture.assertions {
430 render_assertion(
431 out,
432 assertion,
433 result_var,
434 &module,
435 dep_name,
436 true,
437 &[],
438 field_resolver,
439 result_is_tree,
440 );
441 }
442 let _ = writeln!(out, "}}");
443 return;
444 }
445
446 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
448
449 let has_usable_assertion = fixture.assertions.iter().any(|a| {
453 if a.assertion_type == "not_error" || a.assertion_type == "error" {
454 return false;
455 }
456 if a.assertion_type == "method_result" {
457 let supported_checks = [
460 "equals",
461 "is_true",
462 "is_false",
463 "greater_than_or_equal",
464 "count_min",
465 "is_error",
466 "contains",
467 "not_empty",
468 "is_empty",
469 ];
470 let check = a.check.as_deref().unwrap_or("is_true");
471 if a.method.is_none() || !supported_checks.contains(&check) {
472 return false;
473 }
474 }
475 match &a.field {
476 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
477 _ => true,
478 }
479 });
480
481 let result_binding = if has_usable_assertion {
482 result_var.to_string()
483 } else {
484 "_".to_string()
485 };
486
487 let has_field_access = fixture
491 .assertions
492 .iter()
493 .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
494 let only_emptiness_checks = !has_field_access
495 && fixture.assertions.iter().all(|a| {
496 matches!(
497 a.assertion_type.as_str(),
498 "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
499 )
500 });
501
502 if only_emptiness_checks {
503 let _ = writeln!(
505 out,
506 " let {result_binding} = {function_name}({args_str}){await_suffix};"
507 );
508 } else if has_not_error || !fixture.assertions.is_empty() {
509 let _ = writeln!(
510 out,
511 " let {result_binding} = {function_name}({args_str}){await_suffix}.expect(\"should succeed\");"
512 );
513 } else {
514 let _ = writeln!(
515 out,
516 " let {result_binding} = {function_name}({args_str}){await_suffix};"
517 );
518 }
519
520 let string_assertion_types = [
523 "equals",
524 "contains",
525 "contains_all",
526 "contains_any",
527 "not_contains",
528 "starts_with",
529 "ends_with",
530 "min_length",
531 "max_length",
532 "matches_regex",
533 ];
534 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); for assertion in &fixture.assertions {
536 if let Some(f) = &assertion.field {
537 if !f.is_empty()
538 && string_assertion_types.contains(&assertion.assertion_type.as_str())
539 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
540 {
541 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
544 if !is_string_assertion {
545 continue;
546 }
547 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
548 let _ = writeln!(out, " {binding}");
549 unwrapped_fields.push((f.clone(), local_var));
550 }
551 }
552 }
553 }
554
555 for assertion in &fixture.assertions {
557 if assertion.assertion_type == "not_error" {
558 continue;
560 }
561 render_assertion(
562 out,
563 assertion,
564 result_var,
565 &module,
566 dep_name,
567 false,
568 &unwrapped_fields,
569 field_resolver,
570 result_is_tree,
571 );
572 }
573
574 let _ = writeln!(out, "}}");
575}
576
577fn render_rust_arg(
582 name: &str,
583 value: &serde_json::Value,
584 arg_type: &str,
585 optional: bool,
586 module: &str,
587 fixture_id: &str,
588 mock_base_url: Option<&str>,
589) -> (Vec<String>, String) {
590 if arg_type == "mock_url" {
591 let lines = vec![format!(
592 "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
593 )];
594 return (lines, format!("&{name}"));
595 }
596 if arg_type == "base_url" {
598 if let Some(url_expr) = mock_base_url {
599 return (vec![], url_expr.to_string());
600 }
601 }
603 if arg_type == "handle" {
604 use heck::ToSnakeCase;
608 let constructor_name = format!("create_{}", name.to_snake_case());
609 let mut lines = Vec::new();
610 if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
611 lines.push(format!(
612 "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
613 ));
614 } else {
615 let json_literal = serde_json::to_string(value).unwrap_or_default();
617 let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
618 lines.push(format!(
619 "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
620 ));
621 lines.push(format!(
622 "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
623 ));
624 }
625 return (lines, format!("&{name}"));
626 }
627 if arg_type == "json_object" {
628 return render_json_object_arg(name, value, optional, module);
629 }
630 if value.is_null() && !optional {
631 let default_val = match arg_type {
633 "string" => "String::new()".to_string(),
634 "int" | "integer" => "0".to_string(),
635 "float" | "number" => "0.0_f64".to_string(),
636 "bool" | "boolean" => "false".to_string(),
637 _ => "Default::default()".to_string(),
638 };
639 let expr = if arg_type == "string" {
641 format!("&{name}")
642 } else {
643 name.to_string()
644 };
645 return (vec![format!("let {name} = {default_val};")], expr);
646 }
647 let literal = json_to_rust_literal(value, arg_type);
648 let pass_by_ref = arg_type == "string" || arg_type == "bytes";
651 let expr = |n: &str| {
652 if arg_type == "bytes" {
653 format!("{n}.as_bytes()")
654 } else if pass_by_ref {
655 format!("&{n}")
656 } else {
657 n.to_string()
658 }
659 };
660 if optional && value.is_null() {
661 (vec![format!("let {name} = None;")], expr(name))
662 } else if optional {
663 (vec![format!("let {name} = Some({literal});")], expr(name))
664 } else {
665 (vec![format!("let {name} = {literal};")], expr(name))
666 }
667}
668
669fn render_json_object_arg(
673 name: &str,
674 value: &serde_json::Value,
675 optional: bool,
676 _module: &str,
677) -> (Vec<String>, String) {
678 if value.is_null() && optional {
679 return (vec![format!("let {name} = Default::default();")], format!("&{name}"));
682 }
683
684 let normalized = super::normalize_json_keys_to_snake_case(value);
687 let json_literal = json_value_to_macro_literal(&normalized);
689 let mut lines = Vec::new();
690 lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
691 let deser_expr = format!("serde_json::from_value({name}_json).unwrap()");
693 if optional {
694 lines.push(format!("let {name} = Some({deser_expr});"));
695 (lines, format!("&{name}"))
696 } else {
697 lines.push(format!("let {name} = {deser_expr};"));
698 (lines, format!("&{name}"))
699 }
700}
701
702fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
704 match value {
705 serde_json::Value::Null => "null".to_string(),
706 serde_json::Value::Bool(b) => format!("{b}"),
707 serde_json::Value::Number(n) => n.to_string(),
708 serde_json::Value::String(s) => {
709 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
710 format!("\"{escaped}\"")
711 }
712 serde_json::Value::Array(arr) => {
713 let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
714 format!("[{}]", items.join(", "))
715 }
716 serde_json::Value::Object(obj) => {
717 let entries: Vec<String> = obj
718 .iter()
719 .map(|(k, v)| {
720 let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
721 format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
722 })
723 .collect();
724 format!("{{{}}}", entries.join(", "))
725 }
726 }
727}
728
729fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
730 match value {
731 serde_json::Value::Null => "None".to_string(),
732 serde_json::Value::Bool(b) => format!("{b}"),
733 serde_json::Value::Number(n) => {
734 if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
735 if let Some(f) = n.as_f64() {
736 return format!("{f}_f64");
737 }
738 }
739 n.to_string()
740 }
741 serde_json::Value::String(s) => rust_raw_string(s),
742 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
743 let json_str = serde_json::to_string(value).unwrap_or_default();
744 let literal = rust_raw_string(&json_str);
745 format!("serde_json::from_str({literal}).unwrap()")
746 }
747 }
748}
749
750fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
760 let mock = match fixture.mock_response.as_ref() {
761 Some(m) => m,
762 None => return,
763 };
764
765 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
767 let path = call_config.path.as_deref().unwrap_or("/");
768 let method = call_config.method.as_deref().unwrap_or("POST");
769
770 let status = mock.status;
771
772 if let Some(chunks) = &mock.stream_chunks {
773 let _ = writeln!(out, " let mock_route = MockRoute {{");
775 let _ = writeln!(out, " path: \"{path}\",");
776 let _ = writeln!(out, " method: \"{method}\",");
777 let _ = writeln!(out, " status: {status},");
778 let _ = writeln!(out, " body: String::new(),");
779 let _ = writeln!(out, " stream_chunks: vec![");
780 for chunk in chunks {
781 let chunk_str = match chunk {
782 serde_json::Value::String(s) => rust_raw_string(s),
783 other => {
784 let s = serde_json::to_string(other).unwrap_or_default();
785 rust_raw_string(&s)
786 }
787 };
788 let _ = writeln!(out, " {chunk_str}.to_string(),");
789 }
790 let _ = writeln!(out, " ],");
791 let _ = writeln!(out, " }};");
792 } else {
793 let body_str = match &mock.body {
795 Some(b) => {
796 let s = serde_json::to_string(b).unwrap_or_default();
797 rust_raw_string(&s)
798 }
799 None => rust_raw_string("{}"),
800 };
801 let _ = writeln!(out, " let mock_route = MockRoute {{");
802 let _ = writeln!(out, " path: \"{path}\",");
803 let _ = writeln!(out, " method: \"{method}\",");
804 let _ = writeln!(out, " status: {status},");
805 let _ = writeln!(out, " body: {body_str}.to_string(),");
806 let _ = writeln!(out, " stream_chunks: vec![],");
807 let _ = writeln!(out, " }};");
808 }
809
810 let _ = writeln!(out, " let mock_server = MockServer::start(vec![mock_route]).await;");
811}
812
813fn render_mock_server_module() -> String {
815 hash::header(CommentStyle::DoubleSlash)
818 + r#"//
819// Minimal axum-based mock HTTP server for e2e tests.
820
821use std::net::SocketAddr;
822use std::sync::Arc;
823
824use axum::Router;
825use axum::body::Body;
826use axum::extract::State;
827use axum::http::{Request, StatusCode};
828use axum::response::{IntoResponse, Response};
829use tokio::net::TcpListener;
830
831/// A single mock route: match by path + method, return a configured response.
832#[derive(Clone, Debug)]
833pub struct MockRoute {
834 /// URL path to match, e.g. `"/v1/chat/completions"`.
835 pub path: &'static str,
836 /// HTTP method to match, e.g. `"POST"` or `"GET"`.
837 pub method: &'static str,
838 /// HTTP status code to return.
839 pub status: u16,
840 /// Response body JSON string (used when `stream_chunks` is empty).
841 pub body: String,
842 /// Ordered SSE data payloads for streaming responses.
843 /// Each entry becomes `data: <chunk>\n\n` in the response.
844 /// A final `data: [DONE]\n\n` is always appended.
845 pub stream_chunks: Vec<String>,
846}
847
848struct ServerState {
849 routes: Vec<MockRoute>,
850}
851
852pub struct MockServer {
853 /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
854 pub url: String,
855 handle: tokio::task::JoinHandle<()>,
856}
857
858impl MockServer {
859 /// Start a mock server with the given routes. Binds to a random port on
860 /// localhost and returns immediately once the server is listening.
861 pub async fn start(routes: Vec<MockRoute>) -> Self {
862 let state = Arc::new(ServerState { routes });
863
864 let app = Router::new().fallback(handle_request).with_state(state);
865
866 let listener = TcpListener::bind("127.0.0.1:0")
867 .await
868 .expect("Failed to bind mock server port");
869 let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
870 let url = format!("http://{addr}");
871
872 let handle = tokio::spawn(async move {
873 axum::serve(listener, app).await.expect("Mock server failed");
874 });
875
876 MockServer { url, handle }
877 }
878
879 /// Stop the mock server.
880 pub fn shutdown(self) {
881 self.handle.abort();
882 }
883}
884
885impl Drop for MockServer {
886 fn drop(&mut self) {
887 self.handle.abort();
888 }
889}
890
891async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
892 let path = req.uri().path().to_owned();
893 let method = req.method().as_str().to_uppercase();
894
895 for route in &state.routes {
896 if route.path == path && route.method.to_uppercase() == method {
897 let status =
898 StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
899
900 if !route.stream_chunks.is_empty() {
901 // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
902 let mut sse = String::new();
903 for chunk in &route.stream_chunks {
904 sse.push_str("data: ");
905 sse.push_str(chunk);
906 sse.push_str("\n\n");
907 }
908 sse.push_str("data: [DONE]\n\n");
909
910 return Response::builder()
911 .status(status)
912 .header("content-type", "text/event-stream")
913 .header("cache-control", "no-cache")
914 .body(Body::from(sse))
915 .unwrap()
916 .into_response();
917 }
918
919 return Response::builder()
920 .status(status)
921 .header("content-type", "application/json")
922 .body(Body::from(route.body.clone()))
923 .unwrap()
924 .into_response();
925 }
926 }
927
928 // No matching route → 404.
929 Response::builder()
930 .status(StatusCode::NOT_FOUND)
931 .body(Body::from(format!("No mock route for {method} {path}")))
932 .unwrap()
933 .into_response()
934}
935"#
936}
937
938fn render_mock_server_binary() -> String {
950 hash::header(CommentStyle::DoubleSlash)
951 + r#"//
952// Standalone mock HTTP server binary for cross-language e2e tests.
953// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
954//
955// Usage: mock-server [fixtures-dir]
956// fixtures-dir defaults to "../../fixtures"
957//
958// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
959// then blocks until stdin is closed (parent process exit triggers cleanup).
960
961use std::collections::HashMap;
962use std::io::{self, BufRead};
963use std::net::SocketAddr;
964use std::path::Path;
965use std::sync::Arc;
966
967use axum::Router;
968use axum::body::Body;
969use axum::extract::State;
970use axum::http::{Request, StatusCode};
971use axum::response::{IntoResponse, Response};
972use serde::Deserialize;
973use tokio::net::TcpListener;
974
975// ---------------------------------------------------------------------------
976// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
977// ---------------------------------------------------------------------------
978
979#[derive(Debug, Deserialize)]
980struct MockResponse {
981 status: u16,
982 #[serde(default)]
983 body: Option<serde_json::Value>,
984 #[serde(default)]
985 stream_chunks: Option<Vec<serde_json::Value>>,
986}
987
988#[derive(Debug, Deserialize)]
989struct Fixture {
990 id: String,
991 #[serde(default)]
992 mock_response: Option<MockResponse>,
993}
994
995// ---------------------------------------------------------------------------
996// Route table
997// ---------------------------------------------------------------------------
998
999#[derive(Clone, Debug)]
1000struct MockRoute {
1001 status: u16,
1002 body: String,
1003 stream_chunks: Vec<String>,
1004}
1005
1006type RouteTable = Arc<HashMap<String, MockRoute>>;
1007
1008// ---------------------------------------------------------------------------
1009// Axum handler
1010// ---------------------------------------------------------------------------
1011
1012async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
1013 let path = req.uri().path().to_owned();
1014
1015 // Try exact match first
1016 if let Some(route) = routes.get(&path) {
1017 return serve_route(route);
1018 }
1019
1020 // Try prefix match: find a route that is a prefix of the request path
1021 // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
1022 for (route_path, route) in routes.iter() {
1023 if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
1024 return serve_route(route);
1025 }
1026 }
1027
1028 Response::builder()
1029 .status(StatusCode::NOT_FOUND)
1030 .body(Body::from(format!("No mock route for {path}")))
1031 .unwrap()
1032 .into_response()
1033}
1034
1035fn serve_route(route: &MockRoute) -> Response {
1036 let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1037
1038 if !route.stream_chunks.is_empty() {
1039 let mut sse = String::new();
1040 for chunk in &route.stream_chunks {
1041 sse.push_str("data: ");
1042 sse.push_str(chunk);
1043 sse.push_str("\n\n");
1044 }
1045 sse.push_str("data: [DONE]\n\n");
1046
1047 return Response::builder()
1048 .status(status)
1049 .header("content-type", "text/event-stream")
1050 .header("cache-control", "no-cache")
1051 .body(Body::from(sse))
1052 .unwrap()
1053 .into_response();
1054 }
1055
1056 Response::builder()
1057 .status(status)
1058 .header("content-type", "application/json")
1059 .body(Body::from(route.body.clone()))
1060 .unwrap()
1061 .into_response()
1062}
1063
1064// ---------------------------------------------------------------------------
1065// Fixture loading
1066// ---------------------------------------------------------------------------
1067
1068fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
1069 let mut routes = HashMap::new();
1070 load_routes_recursive(fixtures_dir, &mut routes);
1071 routes
1072}
1073
1074fn load_routes_recursive(dir: &Path, routes: &mut HashMap<String, MockRoute>) {
1075 let entries = match std::fs::read_dir(dir) {
1076 Ok(e) => e,
1077 Err(err) => {
1078 eprintln!("warning: cannot read directory {}: {err}", dir.display());
1079 return;
1080 }
1081 };
1082
1083 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
1084 paths.sort();
1085
1086 for path in paths {
1087 if path.is_dir() {
1088 load_routes_recursive(&path, routes);
1089 } else if path.extension().is_some_and(|ext| ext == "json") {
1090 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1091 if filename == "schema.json" || filename.starts_with('_') {
1092 continue;
1093 }
1094 let content = match std::fs::read_to_string(&path) {
1095 Ok(c) => c,
1096 Err(err) => {
1097 eprintln!("warning: cannot read {}: {err}", path.display());
1098 continue;
1099 }
1100 };
1101 let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
1102 match serde_json::from_str(&content) {
1103 Ok(v) => v,
1104 Err(err) => {
1105 eprintln!("warning: cannot parse {}: {err}", path.display());
1106 continue;
1107 }
1108 }
1109 } else {
1110 match serde_json::from_str::<Fixture>(&content) {
1111 Ok(f) => vec![f],
1112 Err(err) => {
1113 eprintln!("warning: cannot parse {}: {err}", path.display());
1114 continue;
1115 }
1116 }
1117 };
1118
1119 for fixture in fixtures {
1120 if let Some(mock) = fixture.mock_response {
1121 let route_path = format!("/fixtures/{}", fixture.id);
1122 let body = mock
1123 .body
1124 .as_ref()
1125 .map(|b| serde_json::to_string(b).unwrap_or_default())
1126 .unwrap_or_default();
1127 let stream_chunks = mock
1128 .stream_chunks
1129 .unwrap_or_default()
1130 .into_iter()
1131 .map(|c| match c {
1132 serde_json::Value::String(s) => s,
1133 other => serde_json::to_string(&other).unwrap_or_default(),
1134 })
1135 .collect();
1136 routes.insert(route_path, MockRoute { status: mock.status, body, stream_chunks });
1137 }
1138 }
1139 }
1140 }
1141}
1142
1143// ---------------------------------------------------------------------------
1144// Entry point
1145// ---------------------------------------------------------------------------
1146
1147#[tokio::main]
1148async fn main() {
1149 let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
1150 let fixtures_dir = Path::new(&fixtures_dir_arg);
1151
1152 let routes = load_routes(fixtures_dir);
1153 eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
1154
1155 let route_table: RouteTable = Arc::new(routes);
1156 let app = Router::new().fallback(handle_request).with_state(route_table);
1157
1158 let listener = TcpListener::bind("127.0.0.1:0")
1159 .await
1160 .expect("mock-server: failed to bind port");
1161 let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
1162
1163 // Print the URL so the parent process can read it.
1164 println!("MOCK_SERVER_URL=http://{addr}");
1165 // Flush stdout explicitly so the parent does not block waiting.
1166 use std::io::Write;
1167 std::io::stdout().flush().expect("mock-server: failed to flush stdout");
1168
1169 // Spawn the server in the background.
1170 tokio::spawn(async move {
1171 axum::serve(listener, app).await.expect("mock-server: server error");
1172 });
1173
1174 // Block until stdin is closed — the parent process controls lifetime.
1175 let stdin = io::stdin();
1176 let mut lines = stdin.lock().lines();
1177 while lines.next().is_some() {}
1178}
1179"#
1180}
1181
1182#[allow(clippy::too_many_arguments)]
1187fn render_assertion(
1188 out: &mut String,
1189 assertion: &Assertion,
1190 result_var: &str,
1191 module: &str,
1192 _dep_name: &str,
1193 is_error_context: bool,
1194 unwrapped_fields: &[(String, String)], field_resolver: &FieldResolver,
1196 result_is_tree: bool,
1197) {
1198 if let Some(f) = &assertion.field {
1200 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1201 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1202 return;
1203 }
1204 }
1205
1206 let field_access = match &assertion.field {
1211 Some(f) if !f.is_empty() => {
1212 if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
1213 local_var.clone()
1214 } else if result_is_tree {
1215 tree_field_access_expr(f, result_var, module)
1218 } else {
1219 field_resolver.accessor(f, "rust", result_var)
1220 }
1221 }
1222 _ => result_var.to_string(),
1223 };
1224
1225 let is_unwrapped = assertion
1227 .field
1228 .as_ref()
1229 .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
1230
1231 match assertion.assertion_type.as_str() {
1232 "error" => {
1233 let _ = writeln!(out, " assert!({result_var}.is_err(), \"expected call to fail\");");
1234 if let Some(serde_json::Value::String(msg)) = &assertion.value {
1235 let escaped = escape_rust(msg);
1236 let _ = writeln!(
1237 out,
1238 " assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
1239 );
1240 }
1241 }
1242 "not_error" => {
1243 }
1245 "equals" => {
1246 if let Some(val) = &assertion.value {
1247 let expected = value_to_rust_string(val);
1248 if is_error_context {
1249 return;
1250 }
1251 if val.is_string() {
1254 let _ = writeln!(
1255 out,
1256 " assert_eq!({field_access}.trim(), {expected}, \"equals assertion failed\");"
1257 );
1258 } else if val.is_boolean() {
1259 if val.as_bool() == Some(true) {
1261 let _ = writeln!(out, " assert!({field_access}, \"equals assertion failed\");");
1262 } else {
1263 let _ = writeln!(out, " assert!(!{field_access}, \"equals assertion failed\");");
1264 }
1265 } else {
1266 let is_opt = assertion.field.as_ref().is_some_and(|f| {
1268 let resolved = field_resolver.resolve(f);
1269 field_resolver.is_optional(resolved)
1270 });
1271 if is_opt
1272 && !unwrapped_fields
1273 .iter()
1274 .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
1275 {
1276 let _ = writeln!(
1277 out,
1278 " assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
1279 );
1280 } else {
1281 let _ = writeln!(
1282 out,
1283 " assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
1284 );
1285 }
1286 }
1287 }
1288 }
1289 "contains" => {
1290 if let Some(val) = &assertion.value {
1291 let expected = value_to_rust_string(val);
1292 let line = format!(
1293 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
1294 );
1295 let _ = writeln!(out, "{line}");
1296 }
1297 }
1298 "contains_all" => {
1299 if let Some(values) = &assertion.values {
1300 for val in values {
1301 let expected = value_to_rust_string(val);
1302 let line = format!(
1303 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
1304 );
1305 let _ = writeln!(out, "{line}");
1306 }
1307 }
1308 }
1309 "not_contains" => {
1310 if let Some(val) = &assertion.value {
1311 let expected = value_to_rust_string(val);
1312 let line = format!(
1313 " assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
1314 );
1315 let _ = writeln!(out, "{line}");
1316 }
1317 }
1318 "not_empty" => {
1319 if let Some(f) = &assertion.field {
1320 let resolved = field_resolver.resolve(f);
1321 if !is_unwrapped && field_resolver.is_optional(resolved) {
1322 let accessor = field_resolver.accessor(f, "rust", result_var);
1324 let _ = writeln!(
1325 out,
1326 " assert!({accessor}.is_some(), \"expected {f} to be present\");"
1327 );
1328 } else {
1329 let _ = writeln!(
1330 out,
1331 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
1332 );
1333 }
1334 } else {
1335 let _ = writeln!(
1337 out,
1338 " assert!({field_access}.is_some(), \"expected non-empty value\");"
1339 );
1340 }
1341 }
1342 "is_empty" => {
1343 if let Some(f) = &assertion.field {
1344 let resolved = field_resolver.resolve(f);
1345 if !is_unwrapped && field_resolver.is_optional(resolved) {
1346 let accessor = field_resolver.accessor(f, "rust", result_var);
1347 let _ = writeln!(out, " assert!({accessor}.is_none(), \"expected {f} to be absent\");");
1348 } else {
1349 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
1350 }
1351 } else {
1352 let _ = writeln!(out, " assert!({field_access}.is_none(), \"expected empty value\");");
1354 }
1355 }
1356 "contains_any" => {
1357 if let Some(values) = &assertion.values {
1358 let checks: Vec<String> = values
1359 .iter()
1360 .map(|v| {
1361 let expected = value_to_rust_string(v);
1362 format!("{field_access}.contains({expected})")
1363 })
1364 .collect();
1365 let joined = checks.join(" || ");
1366 let _ = writeln!(
1367 out,
1368 " assert!({joined}, \"expected to contain at least one of the specified values\");"
1369 );
1370 }
1371 }
1372 "greater_than" => {
1373 if let Some(val) = &assertion.value {
1374 if val.as_f64().is_some_and(|n| n < 0.0) {
1376 let _ = writeln!(
1377 out,
1378 " // skipped: greater_than with negative value is always true for unsigned types"
1379 );
1380 } else if val.as_u64() == Some(0) {
1381 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1383 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected > 0\");");
1384 } else {
1385 let lit = numeric_literal(val);
1386 let _ = writeln!(out, " assert!({field_access} > {lit}, \"expected > {lit}\");");
1387 }
1388 }
1389 }
1390 "less_than" => {
1391 if let Some(val) = &assertion.value {
1392 let lit = numeric_literal(val);
1393 let _ = writeln!(out, " assert!({field_access} < {lit}, \"expected < {lit}\");");
1394 }
1395 }
1396 "greater_than_or_equal" => {
1397 if let Some(val) = &assertion.value {
1398 let lit = numeric_literal(val);
1399 if val.as_u64() == Some(1) && field_access.ends_with(".len()") {
1400 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1404 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= 1\");");
1405 } else {
1406 let _ = writeln!(out, " assert!({field_access} >= {lit}, \"expected >= {lit}\");");
1407 }
1408 }
1409 }
1410 "less_than_or_equal" => {
1411 if let Some(val) = &assertion.value {
1412 let lit = numeric_literal(val);
1413 let _ = writeln!(out, " assert!({field_access} <= {lit}, \"expected <= {lit}\");");
1414 }
1415 }
1416 "starts_with" => {
1417 if let Some(val) = &assertion.value {
1418 let expected = value_to_rust_string(val);
1419 let _ = writeln!(
1420 out,
1421 " assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
1422 );
1423 }
1424 }
1425 "ends_with" => {
1426 if let Some(val) = &assertion.value {
1427 let expected = value_to_rust_string(val);
1428 let _ = writeln!(
1429 out,
1430 " assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
1431 );
1432 }
1433 }
1434 "min_length" => {
1435 if let Some(val) = &assertion.value {
1436 if let Some(n) = val.as_u64() {
1437 let _ = writeln!(
1438 out,
1439 " assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
1440 );
1441 }
1442 }
1443 }
1444 "max_length" => {
1445 if let Some(val) = &assertion.value {
1446 if let Some(n) = val.as_u64() {
1447 let _ = writeln!(
1448 out,
1449 " assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
1450 );
1451 }
1452 }
1453 }
1454 "count_min" => {
1455 if let Some(val) = &assertion.value {
1456 if let Some(n) = val.as_u64() {
1457 if n <= 1 {
1458 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1460 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= {n}\");");
1461 } else {
1462 let _ = writeln!(
1463 out,
1464 " assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
1465 );
1466 }
1467 }
1468 }
1469 }
1470 "count_equals" => {
1471 if let Some(val) = &assertion.value {
1472 if let Some(n) = val.as_u64() {
1473 let _ = writeln!(
1474 out,
1475 " assert_eq!({field_access}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {field_access}.len());"
1476 );
1477 }
1478 }
1479 }
1480 "is_true" => {
1481 let _ = writeln!(out, " assert!({field_access}, \"expected true\");");
1482 }
1483 "is_false" => {
1484 let _ = writeln!(out, " assert!(!{field_access}, \"expected false\");");
1485 }
1486 "method_result" => {
1487 if let Some(method_name) = &assertion.method {
1488 let call_expr = if result_is_tree {
1492 build_tree_call_expr(field_access.as_str(), method_name, assertion.args.as_ref(), module)
1493 } else if let Some(args) = &assertion.args {
1494 let arg_lit = json_to_rust_literal(args, "");
1495 format!("{field_access}.{method_name}({arg_lit})")
1496 } else {
1497 format!("{field_access}.{method_name}()")
1498 };
1499
1500 let returns_numeric = result_is_tree && is_tree_numeric_method(method_name);
1503
1504 let check = assertion.check.as_deref().unwrap_or("is_true");
1505 match check {
1506 "equals" => {
1507 if let Some(val) = &assertion.value {
1508 if val.is_boolean() {
1509 if val.as_bool() == Some(true) {
1510 let _ = writeln!(
1511 out,
1512 " assert!({call_expr}, \"method_result equals assertion failed\");"
1513 );
1514 } else {
1515 let _ = writeln!(
1516 out,
1517 " assert!(!{call_expr}, \"method_result equals assertion failed\");"
1518 );
1519 }
1520 } else {
1521 let expected = value_to_rust_string(val);
1522 let _ = writeln!(
1523 out,
1524 " assert_eq!({call_expr}, {expected}, \"method_result equals assertion failed\");"
1525 );
1526 }
1527 }
1528 }
1529 "is_true" => {
1530 let _ = writeln!(
1531 out,
1532 " assert!({call_expr}, \"method_result is_true assertion failed\");"
1533 );
1534 }
1535 "is_false" => {
1536 let _ = writeln!(
1537 out,
1538 " assert!(!{call_expr}, \"method_result is_false assertion failed\");"
1539 );
1540 }
1541 "greater_than_or_equal" => {
1542 if let Some(val) = &assertion.value {
1543 let lit = numeric_literal(val);
1544 if returns_numeric {
1545 let _ = writeln!(out, " assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
1547 } else if val.as_u64() == Some(1) {
1548 let _ = writeln!(out, " assert!(!{call_expr}.is_empty(), \"expected >= 1\");");
1550 } else {
1551 let _ = writeln!(out, " assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
1552 }
1553 }
1554 }
1555 "count_min" => {
1556 if let Some(val) = &assertion.value {
1557 let n = val.as_u64().unwrap_or(0);
1558 if n <= 1 {
1559 let _ = writeln!(out, " assert!(!{call_expr}.is_empty(), \"expected >= {n}\");");
1560 } else {
1561 let _ = writeln!(
1562 out,
1563 " assert!({call_expr}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {call_expr}.len());"
1564 );
1565 }
1566 }
1567 }
1568 "is_error" => {
1569 let raw_call = call_expr.strip_suffix(".unwrap()").unwrap_or(&call_expr);
1571 let _ = writeln!(
1572 out,
1573 " assert!({raw_call}.is_err(), \"expected method to return error\");"
1574 );
1575 }
1576 "contains" => {
1577 if let Some(val) = &assertion.value {
1578 let expected = value_to_rust_string(val);
1579 let _ = writeln!(
1580 out,
1581 " assert!({call_expr}.contains({expected}), \"expected result to contain {{}}\", {expected});"
1582 );
1583 }
1584 }
1585 "not_empty" => {
1586 let _ = writeln!(
1587 out,
1588 " assert!(!{call_expr}.is_empty(), \"expected non-empty result\");"
1589 );
1590 }
1591 "is_empty" => {
1592 let _ = writeln!(out, " assert!({call_expr}.is_empty(), \"expected empty result\");");
1593 }
1594 other_check => {
1595 panic!("Rust e2e generator: unsupported method_result check type: {other_check}");
1596 }
1597 }
1598 } else {
1599 panic!("Rust e2e generator: method_result assertion missing 'method' field");
1600 }
1601 }
1602 other => {
1603 panic!("Rust e2e generator: unsupported assertion type: {other}");
1604 }
1605 }
1606}
1607
1608fn tree_field_access_expr(field: &str, result_var: &str, module: &str) -> String {
1616 match field {
1617 "root_child_count" => format!("{result_var}.root_node().child_count()"),
1618 "root_node_type" => format!("{result_var}.root_node().kind()"),
1619 "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
1620 "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{result_var})"),
1621 "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{result_var})"),
1622 "tree_to_sexp" => format!("{module}::tree_to_sexp(&{result_var})"),
1623 other => format!("{result_var}.{other}"),
1626 }
1627}
1628
1629fn build_tree_call_expr(
1636 field_access: &str,
1637 method_name: &str,
1638 args: Option<&serde_json::Value>,
1639 module: &str,
1640) -> String {
1641 match method_name {
1642 "root_child_count" => format!("{field_access}.root_node().child_count()"),
1643 "root_node_type" => format!("{field_access}.root_node().kind()"),
1644 "named_children_count" => format!("{field_access}.root_node().named_child_count()"),
1645 "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{field_access})"),
1646 "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{field_access})"),
1647 "tree_to_sexp" => format!("{module}::tree_to_sexp(&{field_access})"),
1648 "contains_node_type" => {
1649 let node_type = args
1650 .and_then(|a| a.get("node_type"))
1651 .and_then(|v| v.as_str())
1652 .unwrap_or("");
1653 format!("{module}::tree_contains_node_type(&{field_access}, \"{node_type}\")")
1654 }
1655 "find_nodes_by_type" => {
1656 let node_type = args
1657 .and_then(|a| a.get("node_type"))
1658 .and_then(|v| v.as_str())
1659 .unwrap_or("");
1660 format!("{module}::find_nodes_by_type(&{field_access}, \"{node_type}\")")
1661 }
1662 "run_query" => {
1663 let query_source = args
1664 .and_then(|a| a.get("query_source"))
1665 .and_then(|v| v.as_str())
1666 .unwrap_or("");
1667 let language = args
1668 .and_then(|a| a.get("language"))
1669 .and_then(|v| v.as_str())
1670 .unwrap_or("");
1671 format!(
1674 "{module}::run_query(&{field_access}, \"{language}\", r#\"{query_source}\"#, source.as_bytes()).unwrap()"
1675 )
1676 }
1677 _ => {
1679 if let Some(args) = args {
1680 let arg_lit = json_to_rust_literal(args, "");
1681 format!("{field_access}.{method_name}({arg_lit})")
1682 } else {
1683 format!("{field_access}.{method_name}()")
1684 }
1685 }
1686 }
1687}
1688
1689fn is_tree_numeric_method(method_name: &str) -> bool {
1693 matches!(
1694 method_name,
1695 "root_child_count" | "named_children_count" | "error_count" | "tree_error_count"
1696 )
1697}
1698
1699fn numeric_literal(value: &serde_json::Value) -> String {
1705 if let Some(n) = value.as_f64() {
1706 if n.fract() == 0.0 {
1707 return format!("{}", n as i64);
1710 }
1711 return format!("{n}_f64");
1712 }
1713 value.to_string()
1715}
1716
1717fn value_to_rust_string(value: &serde_json::Value) -> String {
1718 match value {
1719 serde_json::Value::String(s) => rust_raw_string(s),
1720 serde_json::Value::Bool(b) => format!("{b}"),
1721 serde_json::Value::Number(n) => n.to_string(),
1722 other => {
1723 let s = other.to_string();
1724 format!("\"{s}\"")
1725 }
1726 }
1727}
1728
1729fn resolve_visitor_trait(module: &str) -> String {
1735 if module.contains("html_to_markdown") {
1737 "HtmlVisitor".to_string()
1738 } else {
1739 "Visitor".to_string()
1741 }
1742}
1743
1744fn emit_rust_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1746 let params = match method_name {
1747 "visit_link" => "ctx, href, text, title",
1748 "visit_image" => "ctx, src, alt, title",
1749 "visit_heading" => "ctx, level, text, id",
1750 "visit_code_block" => "ctx, lang, code",
1751 "visit_code_inline"
1752 | "visit_strong"
1753 | "visit_emphasis"
1754 | "visit_strikethrough"
1755 | "visit_underline"
1756 | "visit_subscript"
1757 | "visit_superscript"
1758 | "visit_mark"
1759 | "visit_button"
1760 | "visit_summary"
1761 | "visit_figcaption"
1762 | "visit_definition_term"
1763 | "visit_definition_description" => "ctx, text",
1764 "visit_text" => "ctx, text",
1765 "visit_list_item" => "ctx, ordered, marker, text",
1766 "visit_blockquote" => "ctx, content, depth",
1767 "visit_table_row" => "ctx, cells, is_header",
1768 "visit_custom_element" => "ctx, tag_name, html",
1769 "visit_form" => "ctx, action_url, method",
1770 "visit_input" => "ctx, input_type, name, value",
1771 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1772 "visit_details" => "ctx, is_open",
1773 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1774 "visit_list_start" => "ctx, ordered",
1775 "visit_list_end" => "ctx, ordered, output",
1776 _ => "ctx",
1777 };
1778
1779 let _ = writeln!(out, " fn {method_name}(&self, {params}) -> VisitResult {{");
1780 match action {
1781 CallbackAction::Skip => {
1782 let _ = writeln!(out, " VisitResult::Skip");
1783 }
1784 CallbackAction::Continue => {
1785 let _ = writeln!(out, " VisitResult::Continue");
1786 }
1787 CallbackAction::PreserveHtml => {
1788 let _ = writeln!(out, " VisitResult::PreserveHtml");
1789 }
1790 CallbackAction::Custom { output } => {
1791 let escaped = escape_rust(output);
1792 let _ = writeln!(out, " VisitResult::Custom({escaped}.to_string())");
1793 }
1794 CallbackAction::CustomTemplate { template } => {
1795 let escaped = escape_rust(template);
1796 let _ = writeln!(out, " VisitResult::Custom(format!(\"{escaped}\"))");
1797 }
1798 }
1799 let _ = writeln!(out, " }}");
1800}