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