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, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use anyhow::Result;
13use std::fmt::Write as FmtWrite;
14use std::path::PathBuf;
15
16pub struct RustE2eCodegen;
18
19impl super::E2eCodegen for RustE2eCodegen {
20 fn generate(
21 &self,
22 groups: &[FixtureGroup],
23 e2e_config: &E2eConfig,
24 alef_config: &AlefConfig,
25 ) -> Result<Vec<GeneratedFile>> {
26 let mut files = Vec::new();
27 let output_base = PathBuf::from(e2e_config.effective_output()).join("rust");
28
29 let crate_name = resolve_crate_name(e2e_config, alef_config);
31 let crate_path = resolve_crate_path(e2e_config, &crate_name);
32 let dep_name = crate_name.replace('-', "_");
33
34 let needs_serde_json = e2e_config
37 .call
38 .args
39 .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 crate_version = resolve_crate_version(e2e_config);
49 files.push(GeneratedFile {
50 path: output_base.join("Cargo.toml"),
51 content: render_cargo_toml(
52 &crate_name,
53 &dep_name,
54 &crate_path,
55 needs_serde_json,
56 needs_mock_server,
57 e2e_config.dep_mode,
58 crate_version.as_deref(),
59 ),
60 generated_header: true,
61 });
62
63 if needs_mock_server {
65 files.push(GeneratedFile {
66 path: output_base.join("tests").join("mock_server.rs"),
67 content: render_mock_server_module(),
68 generated_header: true,
69 });
70 }
71
72 for group in groups {
74 let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
75
76 if fixtures.is_empty() {
77 continue;
78 }
79
80 let filename = format!("{}_test.rs", sanitize_filename(&group.category));
81 let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name, needs_mock_server);
82
83 files.push(GeneratedFile {
84 path: output_base.join("tests").join(filename),
85 content,
86 generated_header: true,
87 });
88 }
89
90 Ok(files)
91 }
92
93 fn language_name(&self) -> &'static str {
94 "rust"
95 }
96}
97
98fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
103 alef_config.crate_config.name.clone()
107}
108
109fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
110 e2e_config
111 .resolve_package("rust")
112 .and_then(|p| p.path.clone())
113 .unwrap_or_else(|| format!("../../crates/{crate_name}"))
114}
115
116fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
117 e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
118}
119
120fn resolve_function_name(e2e_config: &E2eConfig) -> String {
121 e2e_config
122 .call
123 .overrides
124 .get("rust")
125 .and_then(|o| o.function.clone())
126 .unwrap_or_else(|| e2e_config.call.function.clone())
127}
128
129fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
130 let overrides = e2e_config.call.overrides.get("rust");
133 overrides
134 .and_then(|o| o.crate_name.clone())
135 .or_else(|| overrides.and_then(|o| o.module.clone()))
136 .unwrap_or_else(|| dep_name.to_string())
137}
138
139fn is_skipped(fixture: &Fixture, language: &str) -> bool {
140 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
141}
142
143fn render_cargo_toml(
148 crate_name: &str,
149 dep_name: &str,
150 crate_path: &str,
151 needs_serde_json: bool,
152 needs_mock_server: bool,
153 dep_mode: crate::config::DependencyMode,
154 version: Option<&str>,
155) -> String {
156 let e2e_name = format!("{dep_name}-e2e-rust");
157 let dep_spec = match dep_mode {
158 crate::config::DependencyMode::Registry => {
159 let ver = version.unwrap_or("0.1.0");
160 if crate_name != dep_name {
161 format!("{dep_name} = {{ package = \"{crate_name}\", version = \"{ver}\" }}")
162 } else {
163 format!("{dep_name} = \"{ver}\"")
164 }
165 }
166 crate::config::DependencyMode::Local => {
167 if crate_name != dep_name {
170 format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\" }}")
171 } else {
172 format!("{dep_name} = {{ path = \"{crate_path}\" }}")
173 }
174 }
175 };
176 let serde_line = if needs_serde_json { "\nserde_json = \"1\"" } else { "" };
177 let workspace_section = "\n[workspace]\n";
184 let mock_lines = if needs_mock_server {
186 "\naxum = \"0.8\"\ntokio-stream = \"0.1\""
187 } else {
188 ""
189 };
190 let mut machete_ignored: Vec<&str> = Vec::new();
191 if needs_serde_json {
192 machete_ignored.push("\"serde_json\"");
193 }
194 if needs_mock_server {
195 machete_ignored.push("\"axum\"");
196 machete_ignored.push("\"tokio-stream\"");
197 }
198 let machete_section = if machete_ignored.is_empty() {
199 String::new()
200 } else {
201 format!(
202 "\n[package.metadata.cargo-machete]\nignored = [{}]\n",
203 machete_ignored.join(", ")
204 )
205 };
206 format!(
207 r#"# This file is auto-generated by alef. DO NOT EDIT.
208{workspace_section}
209[package]
210name = "{e2e_name}"
211version = "0.1.0"
212edition = "2021"
213license = "MIT"
214publish = false
215
216[dependencies]
217{dep_spec}{serde_line}{mock_lines}
218tokio = {{ version = "1", features = ["full"] }}
219{machete_section}"#
220 )
221}
222
223fn render_test_file(
224 category: &str,
225 fixtures: &[&Fixture],
226 e2e_config: &E2eConfig,
227 dep_name: &str,
228 needs_mock_server: bool,
229) -> String {
230 let mut out = String::new();
231 let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
232 let _ = writeln!(out, "//! E2e tests for category: {category}");
233 let _ = writeln!(out);
234
235 let module = resolve_module(e2e_config, dep_name);
236 let function_name = resolve_function_name(e2e_config);
237 let field_resolver = FieldResolver::new(
238 &e2e_config.fields,
239 &e2e_config.fields_optional,
240 &e2e_config.result_fields,
241 &e2e_config.fields_array,
242 );
243
244 let _ = writeln!(out, "use {module}::{function_name};");
245
246 let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
248 if has_handle_args {
249 let _ = writeln!(out, "use {module}::CrawlConfig;");
250 }
251 for arg in &e2e_config.call.args {
252 if arg.arg_type == "handle" {
253 use heck::ToSnakeCase;
254 let constructor_name = format!("create_{}", arg.name.to_snake_case());
255 let _ = writeln!(out, "use {module}::{constructor_name};");
256 }
257 }
258
259 let file_needs_mock = needs_mock_server && fixtures.iter().any(|f| f.needs_mock_server());
261 if file_needs_mock {
262 let _ = writeln!(out, "mod mock_server;");
263 let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
264 }
265
266 let _ = writeln!(out);
267
268 for fixture in fixtures {
269 render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
270 let _ = writeln!(out);
271 }
272
273 if !out.ends_with('\n') {
274 out.push('\n');
275 }
276 out
277}
278
279fn render_test_function(
280 out: &mut String,
281 fixture: &Fixture,
282 e2e_config: &E2eConfig,
283 dep_name: &str,
284 field_resolver: &FieldResolver,
285) {
286 let fn_name = sanitize_ident(&fixture.id);
287 let description = &fixture.description;
288 let function_name = resolve_function_name(e2e_config);
289 let module = resolve_module(e2e_config, dep_name);
290 let result_var = &e2e_config.call.result_var;
291 let has_mock = fixture.needs_mock_server();
292
293 let is_async = e2e_config.call.r#async || has_mock;
295 if is_async {
296 let _ = writeln!(out, "#[tokio::test]");
297 let _ = writeln!(out, "async fn test_{fn_name}() {{");
298 } else {
299 let _ = writeln!(out, "#[test]");
300 let _ = writeln!(out, "fn test_{fn_name}() {{");
301 }
302 let _ = writeln!(out, " // {description}");
303
304 if has_mock {
307 render_mock_server_setup(out, fixture, e2e_config);
308 }
309
310 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
312
313 let mut arg_exprs: Vec<String> = Vec::new();
315 for arg in &e2e_config.call.args {
316 let value = resolve_field(&fixture.input, &arg.field);
317 let var_name = &arg.name;
318 let (bindings, expr) = render_rust_arg(
319 var_name,
320 value,
321 &arg.arg_type,
322 arg.optional,
323 &module,
324 &fixture.id,
325 if has_mock { Some("mock_server.url.as_str()") } else { None },
326 );
327 for binding in &bindings {
328 let _ = writeln!(out, " {binding}");
329 }
330 arg_exprs.push(expr);
331 }
332
333 let args_str = arg_exprs.join(", ");
334
335 let await_suffix = if is_async { ".await" } else { "" };
336
337 if has_error_assertion {
338 let _ = writeln!(out, " let {result_var} = {function_name}({args_str}){await_suffix};");
339 for assertion in &fixture.assertions {
341 render_assertion(out, assertion, result_var, dep_name, true, &[], field_resolver);
342 }
343 let _ = writeln!(out, "}}");
344 return;
345 }
346
347 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
349
350 let has_usable_assertion = fixture.assertions.iter().any(|a| {
354 if a.assertion_type == "not_error" || a.assertion_type == "error" {
355 return false;
356 }
357 match &a.field {
358 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
359 _ => true,
360 }
361 });
362
363 let result_binding = if has_usable_assertion {
364 result_var.to_string()
365 } else {
366 "_".to_string()
367 };
368
369 if has_not_error || !fixture.assertions.is_empty() {
370 let _ = writeln!(
371 out,
372 " let {result_binding} = {function_name}({args_str}){await_suffix}.expect(\"should succeed\");"
373 );
374 } else {
375 let _ = writeln!(
376 out,
377 " let {result_binding} = {function_name}({args_str}){await_suffix};"
378 );
379 }
380
381 let string_assertion_types = [
384 "equals",
385 "contains",
386 "contains_all",
387 "contains_any",
388 "not_contains",
389 "starts_with",
390 "ends_with",
391 "min_length",
392 "max_length",
393 "matches_regex",
394 ];
395 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); for assertion in &fixture.assertions {
397 if let Some(f) = &assertion.field {
398 if !f.is_empty()
399 && string_assertion_types.contains(&assertion.assertion_type.as_str())
400 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
401 {
402 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
405 if !is_string_assertion {
406 continue;
407 }
408 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
409 let _ = writeln!(out, " {binding}");
410 unwrapped_fields.push((f.clone(), local_var));
411 }
412 }
413 }
414 }
415
416 for assertion in &fixture.assertions {
418 if assertion.assertion_type == "not_error" {
419 continue;
421 }
422 render_assertion(
423 out,
424 assertion,
425 result_var,
426 dep_name,
427 false,
428 &unwrapped_fields,
429 field_resolver,
430 );
431 }
432
433 let _ = writeln!(out, "}}");
434}
435
436fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
441 let mut current = input;
442 for part in field_path.split('.') {
443 current = current.get(part).unwrap_or(&serde_json::Value::Null);
444 }
445 current
446}
447
448fn render_rust_arg(
449 name: &str,
450 value: &serde_json::Value,
451 arg_type: &str,
452 optional: bool,
453 module: &str,
454 fixture_id: &str,
455 mock_base_url: Option<&str>,
456) -> (Vec<String>, String) {
457 if arg_type == "mock_url" {
458 let lines = vec![format!(
459 "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
460 )];
461 return (lines, format!("&{name}"));
462 }
463 if arg_type == "base_url" {
465 if let Some(url_expr) = mock_base_url {
466 return (vec![], url_expr.to_string());
467 }
468 }
470 if arg_type == "handle" {
471 use heck::ToSnakeCase;
475 let constructor_name = format!("create_{}", name.to_snake_case());
476 let mut lines = Vec::new();
477 if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
478 lines.push(format!(
479 "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
480 ));
481 } else {
482 let json_literal = serde_json::to_string(value).unwrap_or_default();
484 let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
485 lines.push(format!(
486 "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
487 ));
488 lines.push(format!(
489 "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
490 ));
491 }
492 return (lines, format!("&{name}"));
493 }
494 if arg_type == "json_object" {
495 return render_json_object_arg(name, value, optional, module);
496 }
497 if value.is_null() && !optional {
498 let default_val = match arg_type {
500 "string" => "String::new()".to_string(),
501 "int" | "integer" => "0".to_string(),
502 "float" | "number" => "0.0_f64".to_string(),
503 "bool" | "boolean" => "false".to_string(),
504 _ => "Default::default()".to_string(),
505 };
506 let expr = if arg_type == "string" {
508 format!("&{name}")
509 } else {
510 name.to_string()
511 };
512 return (vec![format!("let {name} = {default_val};")], expr);
513 }
514 let literal = json_to_rust_literal(value, arg_type);
515 let pass_by_ref = arg_type == "string";
517 let expr = |n: &str| if pass_by_ref { format!("&{n}") } else { n.to_string() };
518 if optional && value.is_null() {
519 (vec![format!("let {name} = None;")], expr(name))
520 } else if optional {
521 (vec![format!("let {name} = Some({literal});")], expr(name))
522 } else {
523 (vec![format!("let {name} = {literal};")], expr(name))
524 }
525}
526
527fn render_json_object_arg(
531 name: &str,
532 value: &serde_json::Value,
533 optional: bool,
534 _module: &str,
535) -> (Vec<String>, String) {
536 if value.is_null() && optional {
537 return (vec![format!("let {name} = None;")], name.to_string());
538 }
539
540 let normalized = super::normalize_json_keys_to_snake_case(value);
543 let json_literal = json_value_to_macro_literal(&normalized);
545 let mut lines = Vec::new();
546 lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
547 let deser_expr = format!("serde_json::from_value({name}_json).unwrap()");
549 if optional {
550 lines.push(format!("let {name} = Some({deser_expr});"));
551 } else {
552 lines.push(format!("let {name} = {deser_expr};"));
553 }
554 (lines, name.to_string())
555}
556
557fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
559 match value {
560 serde_json::Value::Null => "null".to_string(),
561 serde_json::Value::Bool(b) => format!("{b}"),
562 serde_json::Value::Number(n) => n.to_string(),
563 serde_json::Value::String(s) => {
564 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
565 format!("\"{escaped}\"")
566 }
567 serde_json::Value::Array(arr) => {
568 let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
569 format!("[{}]", items.join(", "))
570 }
571 serde_json::Value::Object(obj) => {
572 let entries: Vec<String> = obj
573 .iter()
574 .map(|(k, v)| {
575 let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
576 format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
577 })
578 .collect();
579 format!("{{{}}}", entries.join(", "))
580 }
581 }
582}
583
584fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
585 match value {
586 serde_json::Value::Null => "None".to_string(),
587 serde_json::Value::Bool(b) => format!("{b}"),
588 serde_json::Value::Number(n) => {
589 if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
590 if let Some(f) = n.as_f64() {
591 return format!("{f}_f64");
592 }
593 }
594 n.to_string()
595 }
596 serde_json::Value::String(s) => rust_raw_string(s),
597 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
598 let json_str = serde_json::to_string(value).unwrap_or_default();
599 let literal = rust_raw_string(&json_str);
600 format!("serde_json::from_str({literal}).unwrap()")
601 }
602 }
603}
604
605fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
615 let mock = match fixture.mock_response.as_ref() {
616 Some(m) => m,
617 None => return,
618 };
619
620 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
622 let path = call_config
623 .path
624 .as_deref()
625 .unwrap_or("/");
626 let method = call_config
627 .method
628 .as_deref()
629 .unwrap_or("POST");
630
631 let status = mock.status;
632
633 if let Some(chunks) = &mock.stream_chunks {
634 let _ = writeln!(out, " let mock_route = MockRoute {{");
636 let _ = writeln!(out, " path: \"{path}\",");
637 let _ = writeln!(out, " method: \"{method}\",");
638 let _ = writeln!(out, " status: {status},");
639 let _ = writeln!(out, " body: String::new(),");
640 let _ = writeln!(out, " stream_chunks: vec![");
641 for chunk in chunks {
642 let chunk_str = match chunk {
643 serde_json::Value::String(s) => rust_raw_string(s),
644 other => {
645 let s = serde_json::to_string(other).unwrap_or_default();
646 rust_raw_string(&s)
647 }
648 };
649 let _ = writeln!(out, " {chunk_str}.to_string(),");
650 }
651 let _ = writeln!(out, " ],");
652 let _ = writeln!(out, " }};");
653 } else {
654 let body_str = match &mock.body {
656 Some(b) => {
657 let s = serde_json::to_string(b).unwrap_or_default();
658 rust_raw_string(&s)
659 }
660 None => rust_raw_string("{}"),
661 };
662 let _ = writeln!(out, " let mock_route = MockRoute {{");
663 let _ = writeln!(out, " path: \"{path}\",");
664 let _ = writeln!(out, " method: \"{method}\",");
665 let _ = writeln!(out, " status: {status},");
666 let _ = writeln!(out, " body: {body_str}.to_string(),");
667 let _ = writeln!(out, " stream_chunks: vec![],");
668 let _ = writeln!(out, " }};");
669 }
670
671 let _ = writeln!(out, " let mock_server = MockServer::start(vec![mock_route]).await;");
672}
673
674fn render_mock_server_module() -> String {
676 r#"// This file is auto-generated by alef. DO NOT EDIT.
679//
680// Minimal axum-based mock HTTP server for e2e tests.
681
682use std::net::SocketAddr;
683use std::sync::Arc;
684
685use axum::Router;
686use axum::body::Body;
687use axum::extract::State;
688use axum::http::{Request, StatusCode};
689use axum::response::{IntoResponse, Response};
690use tokio::net::TcpListener;
691
692/// A single mock route: match by path + method, return a configured response.
693#[derive(Clone, Debug)]
694pub struct MockRoute {
695 /// URL path to match, e.g. `"/v1/chat/completions"`.
696 pub path: &'static str,
697 /// HTTP method to match, e.g. `"POST"` or `"GET"`.
698 pub method: &'static str,
699 /// HTTP status code to return.
700 pub status: u16,
701 /// Response body JSON string (used when `stream_chunks` is empty).
702 pub body: String,
703 /// Ordered SSE data payloads for streaming responses.
704 /// Each entry becomes `data: <chunk>\n\n` in the response.
705 /// A final `data: [DONE]\n\n` is always appended.
706 pub stream_chunks: Vec<String>,
707}
708
709struct ServerState {
710 routes: Vec<MockRoute>,
711}
712
713pub struct MockServer {
714 /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
715 pub url: String,
716 handle: tokio::task::JoinHandle<()>,
717}
718
719impl MockServer {
720 /// Start a mock server with the given routes. Binds to a random port on
721 /// localhost and returns immediately once the server is listening.
722 pub async fn start(routes: Vec<MockRoute>) -> Self {
723 let state = Arc::new(ServerState { routes });
724
725 let app = Router::new().fallback(handle_request).with_state(state);
726
727 let listener = TcpListener::bind("127.0.0.1:0")
728 .await
729 .expect("Failed to bind mock server port");
730 let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
731 let url = format!("http://{addr}");
732
733 let handle = tokio::spawn(async move {
734 axum::serve(listener, app).await.expect("Mock server failed");
735 });
736
737 MockServer { url, handle }
738 }
739
740 /// Stop the mock server.
741 pub fn shutdown(self) {
742 self.handle.abort();
743 }
744}
745
746impl Drop for MockServer {
747 fn drop(&mut self) {
748 self.handle.abort();
749 }
750}
751
752async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
753 let path = req.uri().path().to_owned();
754 let method = req.method().as_str().to_uppercase();
755
756 for route in &state.routes {
757 if route.path == path && route.method.to_uppercase() == method {
758 let status =
759 StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
760
761 if !route.stream_chunks.is_empty() {
762 // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
763 let mut sse = String::new();
764 for chunk in &route.stream_chunks {
765 sse.push_str("data: ");
766 sse.push_str(chunk);
767 sse.push_str("\n\n");
768 }
769 sse.push_str("data: [DONE]\n\n");
770
771 return Response::builder()
772 .status(status)
773 .header("content-type", "text/event-stream")
774 .header("cache-control", "no-cache")
775 .body(Body::from(sse))
776 .unwrap()
777 .into_response();
778 }
779
780 return Response::builder()
781 .status(status)
782 .header("content-type", "application/json")
783 .body(Body::from(route.body.clone()))
784 .unwrap()
785 .into_response();
786 }
787 }
788
789 // No matching route → 404.
790 Response::builder()
791 .status(StatusCode::NOT_FOUND)
792 .body(Body::from(format!("No mock route for {method} {path}")))
793 .unwrap()
794 .into_response()
795}
796"#
797 .to_string()
798}
799
800fn render_assertion(
805 out: &mut String,
806 assertion: &Assertion,
807 result_var: &str,
808 _dep_name: &str,
809 is_error_context: bool,
810 unwrapped_fields: &[(String, String)], field_resolver: &FieldResolver,
812) {
813 if let Some(f) = &assertion.field {
815 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
816 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
817 return;
818 }
819 }
820
821 let field_access = match &assertion.field {
825 Some(f) if !f.is_empty() => {
826 if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
827 local_var.clone()
828 } else {
829 field_resolver.accessor(f, "rust", result_var)
830 }
831 }
832 _ => result_var.to_string(),
833 };
834
835 let is_unwrapped = assertion
837 .field
838 .as_ref()
839 .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
840
841 match assertion.assertion_type.as_str() {
842 "error" => {
843 let _ = writeln!(out, " assert!({result_var}.is_err(), \"expected call to fail\");");
844 if let Some(serde_json::Value::String(msg)) = &assertion.value {
845 let escaped = escape_rust(msg);
846 let _ = writeln!(
847 out,
848 " assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
849 );
850 }
851 }
852 "not_error" => {
853 }
855 "equals" => {
856 if let Some(val) = &assertion.value {
857 let expected = value_to_rust_string(val);
858 if is_error_context {
859 return;
860 }
861 if val.is_string() {
864 let _ = writeln!(
865 out,
866 " assert_eq!({field_access}.trim(), {expected}, \"equals assertion failed\");"
867 );
868 } else if val.is_boolean() {
869 if val.as_bool() == Some(true) {
871 let _ = writeln!(out, " assert!({field_access}, \"equals assertion failed\");");
872 } else {
873 let _ = writeln!(out, " assert!(!{field_access}, \"equals assertion failed\");");
874 }
875 } else {
876 let is_opt = assertion.field.as_ref().is_some_and(|f| {
878 let resolved = field_resolver.resolve(f);
879 field_resolver.is_optional(resolved)
880 });
881 if is_opt
882 && !unwrapped_fields
883 .iter()
884 .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
885 {
886 let _ = writeln!(
887 out,
888 " assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
889 );
890 } else {
891 let _ = writeln!(
892 out,
893 " assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
894 );
895 }
896 }
897 }
898 }
899 "contains" => {
900 if let Some(val) = &assertion.value {
901 let expected = value_to_rust_string(val);
902 let line = format!(
903 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
904 );
905 let _ = writeln!(out, "{line}");
906 }
907 }
908 "contains_all" => {
909 if let Some(values) = &assertion.values {
910 for val in values {
911 let expected = value_to_rust_string(val);
912 let line = format!(
913 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
914 );
915 let _ = writeln!(out, "{line}");
916 }
917 }
918 }
919 "not_contains" => {
920 if let Some(val) = &assertion.value {
921 let expected = value_to_rust_string(val);
922 let line = format!(
923 " assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
924 );
925 let _ = writeln!(out, "{line}");
926 }
927 }
928 "not_empty" => {
929 if let Some(f) = &assertion.field {
930 let resolved = field_resolver.resolve(f);
931 if !is_unwrapped && field_resolver.is_optional(resolved) {
932 let accessor = field_resolver.accessor(f, "rust", result_var);
934 let _ = writeln!(
935 out,
936 " assert!({accessor}.is_some(), \"expected {f} to be present\");"
937 );
938 } else {
939 let _ = writeln!(
940 out,
941 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
942 );
943 }
944 } else {
945 let _ = writeln!(
946 out,
947 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
948 );
949 }
950 }
951 "is_empty" => {
952 if let Some(f) = &assertion.field {
953 let resolved = field_resolver.resolve(f);
954 if !is_unwrapped && field_resolver.is_optional(resolved) {
955 let accessor = field_resolver.accessor(f, "rust", result_var);
956 let _ = writeln!(out, " assert!({accessor}.is_none(), \"expected {f} to be absent\");");
957 } else {
958 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
959 }
960 } else {
961 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
962 }
963 }
964 "contains_any" => {
965 if let Some(values) = &assertion.values {
966 let checks: Vec<String> = values
967 .iter()
968 .map(|v| {
969 let expected = value_to_rust_string(v);
970 format!("{field_access}.contains({expected})")
971 })
972 .collect();
973 let joined = checks.join(" || ");
974 let _ = writeln!(
975 out,
976 " assert!({joined}, \"expected to contain at least one of the specified values\");"
977 );
978 }
979 }
980 "greater_than" => {
981 if let Some(val) = &assertion.value {
982 if val.as_f64().is_some_and(|n| n < 0.0) {
984 let _ = writeln!(
985 out,
986 " // skipped: greater_than with negative value is always true for unsigned types"
987 );
988 } else if val.as_u64() == Some(0) {
989 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
991 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected > 0\");");
992 } else {
993 let lit = numeric_literal(val);
994 let _ = writeln!(out, " assert!({field_access} > {lit}, \"expected > {lit}\");");
995 }
996 }
997 }
998 "less_than" => {
999 if let Some(val) = &assertion.value {
1000 let lit = numeric_literal(val);
1001 let _ = writeln!(out, " assert!({field_access} < {lit}, \"expected < {lit}\");");
1002 }
1003 }
1004 "greater_than_or_equal" => {
1005 if let Some(val) = &assertion.value {
1006 if val.as_u64() == Some(1) {
1007 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1009 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= 1\");");
1010 } else {
1011 let lit = numeric_literal(val);
1012 let _ = writeln!(out, " assert!({field_access} >= {lit}, \"expected >= {lit}\");");
1013 }
1014 }
1015 }
1016 "less_than_or_equal" => {
1017 if let Some(val) = &assertion.value {
1018 let lit = numeric_literal(val);
1019 let _ = writeln!(out, " assert!({field_access} <= {lit}, \"expected <= {lit}\");");
1020 }
1021 }
1022 "starts_with" => {
1023 if let Some(val) = &assertion.value {
1024 let expected = value_to_rust_string(val);
1025 let _ = writeln!(
1026 out,
1027 " assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
1028 );
1029 }
1030 }
1031 "ends_with" => {
1032 if let Some(val) = &assertion.value {
1033 let expected = value_to_rust_string(val);
1034 let _ = writeln!(
1035 out,
1036 " assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
1037 );
1038 }
1039 }
1040 "min_length" => {
1041 if let Some(val) = &assertion.value {
1042 if let Some(n) = val.as_u64() {
1043 let _ = writeln!(
1044 out,
1045 " assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
1046 );
1047 }
1048 }
1049 }
1050 "max_length" => {
1051 if let Some(val) = &assertion.value {
1052 if let Some(n) = val.as_u64() {
1053 let _ = writeln!(
1054 out,
1055 " assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
1056 );
1057 }
1058 }
1059 }
1060 "count_min" => {
1061 if let Some(val) = &assertion.value {
1062 if let Some(n) = val.as_u64() {
1063 if n <= 1 {
1064 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1066 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= {n}\");");
1067 } else {
1068 let _ = writeln!(
1069 out,
1070 " assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
1071 );
1072 }
1073 }
1074 }
1075 }
1076 other => {
1077 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
1078 }
1079 }
1080}
1081
1082fn numeric_literal(value: &serde_json::Value) -> String {
1088 if let Some(n) = value.as_f64() {
1089 if n.fract() == 0.0 {
1090 return format!("{}", n as i64);
1093 }
1094 return format!("{n}_f64");
1095 }
1096 value.to_string()
1098}
1099
1100fn value_to_rust_string(value: &serde_json::Value) -> String {
1101 match value {
1102 serde_json::Value::String(s) => rust_raw_string(s),
1103 serde_json::Value::Bool(b) => format!("{b}"),
1104 serde_json::Value::Number(n) => n.to_string(),
1105 other => {
1106 let s = other.to_string();
1107 format!("\"{s}\"")
1108 }
1109 }
1110}