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