1use std::fmt::Write as FmtWrite;
4
5use alef_core::hash::{self, CommentStyle};
6
7use crate::config::E2eConfig;
8use crate::escape::rust_raw_string;
9use crate::fixture::Fixture;
10
11pub fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
17 let mut routes = Vec::new();
19
20 if let Some(mock_responses) = fixture.input.get("mock_responses").and_then(|v| v.as_array()) {
21 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
23 let default_path = call_config.path.as_deref().unwrap_or("/");
24 let default_method = call_config.method.as_deref().unwrap_or("POST");
25
26 for response in mock_responses {
27 if let Ok(obj) = serde_json::from_value::<serde_json::Map<String, serde_json::Value>>(response.clone()) {
28 let path = obj
29 .get("path")
30 .and_then(|v| v.as_str())
31 .unwrap_or(default_path)
32 .to_string();
33 let method = obj
34 .get("method")
35 .and_then(|v| v.as_str())
36 .unwrap_or(default_method)
37 .to_string();
38 let status: u16 = obj.get("status_code").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
39
40 let headers: Vec<(String, String)> = obj
41 .get("headers")
42 .and_then(|v| v.as_object())
43 .map(|h| {
44 let mut entries: Vec<_> = h
45 .iter()
46 .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
47 .collect();
48 entries.sort_by(|a, b| a.0.cmp(&b.0));
49 entries
50 })
51 .unwrap_or_default();
52
53 let body_str = if let Some(body_inline) = obj.get("body_inline").and_then(|v| v.as_str()) {
54 rust_raw_string(body_inline)
55 } else {
56 rust_raw_string("{}")
59 };
60
61 let delay_ms = obj.get("delay_ms").and_then(|v| v.as_u64());
62
63 routes.push((path, method, status, body_str, headers, delay_ms));
64 }
65 }
66 } else if let Some(mock) = fixture.mock_response.as_ref() {
67 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
69 let path = call_config.path.as_deref().unwrap_or("/");
70 let method = call_config.method.as_deref().unwrap_or("POST");
71
72 let status = mock.status;
73
74 let mut header_entries: Vec<(&String, &String)> = mock.headers.iter().collect();
76 header_entries.sort_by(|a, b| a.0.cmp(b.0));
77 let header_tuples: Vec<(String, String)> = header_entries
78 .into_iter()
79 .map(|(k, v)| (k.clone(), v.clone()))
80 .collect();
81
82 let body_str = match &mock.body {
83 Some(b) => {
84 let s = serde_json::to_string(b).unwrap_or_default();
85 rust_raw_string(&s)
86 }
87 None => rust_raw_string("{}"),
88 };
89
90 if let Some(chunks) = &mock.stream_chunks {
92 let _ = writeln!(out, " let mock_route = MockRoute {{");
94 let _ = writeln!(out, " path: \"{path}\",");
95 let _ = writeln!(out, " method: \"{method}\",");
96 let _ = writeln!(out, " status: {status},");
97 let _ = writeln!(out, " body: String::new(),");
98 let _ = writeln!(out, " stream_chunks: vec![");
99 for chunk in chunks {
100 let chunk_str = match chunk {
101 serde_json::Value::String(s) => rust_raw_string(s),
102 other => {
103 let s = serde_json::to_string(other).unwrap_or_default();
104 rust_raw_string(&s)
105 }
106 };
107 let _ = writeln!(out, " {chunk_str}.to_string(),");
108 }
109 let _ = writeln!(out, " ],");
110 let _ = writeln!(out, " headers: vec![");
111 for (name, value) in &header_tuples {
112 let n = rust_raw_string(name);
113 let v = rust_raw_string(value);
114 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
115 }
116 let _ = writeln!(out, " ],");
117 let _ = writeln!(out, " delay_ms: None,");
118 let _ = writeln!(out, " }};");
119 let _ = writeln!(out, " let mock_server = MockServer::start(vec![mock_route]).await;");
120 return;
121 }
122
123 routes.push((
124 path.to_string(),
125 method.to_string(),
126 status,
127 body_str,
128 header_tuples,
129 None,
130 ));
131 } else {
132 return;
133 }
134
135 if routes.len() == 1 {
137 let (path, method, status, body_str, header_entries, delay_ms) = routes.pop().unwrap();
138 let delay_literal = match delay_ms {
139 Some(ms) => format!("Some({ms})"),
140 None => "None".to_string(),
141 };
142 let _ = writeln!(out, " let mock_route = MockRoute {{");
143 let _ = writeln!(out, " path: \"{path}\",");
144 let _ = writeln!(out, " method: \"{method}\",");
145 let _ = writeln!(out, " status: {status},");
146 let _ = writeln!(out, " body: {body_str}.to_string(),");
147 let _ = writeln!(out, " stream_chunks: vec![],");
148 let _ = writeln!(out, " headers: vec![");
149 for (name, value) in &header_entries {
150 let n = rust_raw_string(name);
151 let v = rust_raw_string(value);
152 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
153 }
154 let _ = writeln!(out, " ],");
155 let _ = writeln!(out, " delay_ms: {delay_literal},");
156 let _ = writeln!(out, " }};");
157 let _ = writeln!(out, " let mock_server = MockServer::start(vec![mock_route]).await;");
158 } else {
159 let _ = writeln!(out, " let mut mock_routes = vec![];");
161 for (path, method, status, body_str, header_entries, delay_ms) in routes {
162 let delay_literal = match delay_ms {
163 Some(ms) => format!("Some({ms})"),
164 None => "None".to_string(),
165 };
166 let _ = writeln!(out, " mock_routes.push(MockRoute {{");
167 let _ = writeln!(out, " path: \"{path}\",");
168 let _ = writeln!(out, " method: \"{method}\",");
169 let _ = writeln!(out, " status: {status},");
170 let _ = writeln!(out, " body: {body_str}.to_string(),");
171 let _ = writeln!(out, " stream_chunks: vec![],");
172 let _ = writeln!(out, " headers: vec![");
173 for (name, value) in &header_entries {
174 let n = rust_raw_string(name);
175 let v = rust_raw_string(value);
176 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
177 }
178 let _ = writeln!(out, " ],");
179 let _ = writeln!(out, " delay_ms: {delay_literal},");
180 let _ = writeln!(out, " }});");
181 }
182 let _ = writeln!(out, " let mock_server = MockServer::start(mock_routes).await;");
183 }
184}
185
186pub fn render_mock_server_module() -> String {
188 hash::header(CommentStyle::DoubleSlash)
191 + r#"//
192// Minimal axum-based mock HTTP server for e2e tests.
193
194use std::net::SocketAddr;
195use std::sync::Arc;
196
197use axum::Router;
198use axum::body::Body;
199use axum::extract::State;
200use axum::http::{Request, StatusCode};
201use axum::response::{IntoResponse, Response};
202use tokio::net::TcpListener;
203
204/// A single mock route: match by path + method, return a configured response.
205#[derive(Clone, Debug)]
206pub struct MockRoute {
207 /// URL path to match, e.g. `"/v1/chat/completions"`.
208 pub path: &'static str,
209 /// HTTP method to match, e.g. `"POST"` or `"GET"`.
210 pub method: &'static str,
211 /// HTTP status code to return.
212 pub status: u16,
213 /// Response body JSON string (used when `stream_chunks` is empty).
214 pub body: String,
215 /// Ordered SSE data payloads for streaming responses.
216 /// Each entry becomes `data: <chunk>\n\n` in the response.
217 /// A final `data: [DONE]\n\n` is always appended.
218 pub stream_chunks: Vec<String>,
219 /// Response headers to apply (name, value) pairs.
220 /// Multiple entries with the same name produce multiple header lines.
221 pub headers: Vec<(String, String)>,
222 /// Optional artificial response delay in milliseconds. Used by timeout-error
223 /// fixtures to force a client-side request timeout.
224 pub delay_ms: Option<u64>,
225}
226
227struct ServerState {
228 routes: Vec<MockRoute>,
229}
230
231pub struct MockServer {
232 /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
233 pub url: String,
234 handle: tokio::task::JoinHandle<()>,
235}
236
237impl MockServer {
238 /// Start a mock server with the given routes. Binds to a random port on
239 /// localhost and returns immediately once the server is listening.
240 pub async fn start(routes: Vec<MockRoute>) -> Self {
241 let state = Arc::new(ServerState { routes });
242
243 let app = Router::new().fallback(handle_request).with_state(state);
244
245 let listener = TcpListener::bind("127.0.0.1:0")
246 .await
247 .expect("Failed to bind mock server port");
248 let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
249 let url = format!("http://{addr}");
250
251 let handle = tokio::spawn(async move {
252 axum::serve(listener, app).await.expect("Mock server failed");
253 });
254
255 MockServer { url, handle }
256 }
257
258 /// Stop the mock server.
259 pub fn shutdown(self) {
260 self.handle.abort();
261 }
262}
263
264impl Drop for MockServer {
265 fn drop(&mut self) {
266 self.handle.abort();
267 }
268}
269
270async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
271 let path = req.uri().path().to_owned();
272 let method = req.method().as_str().to_uppercase();
273
274 for route in &state.routes {
275 // Match on method and either exact path or path prefix (route.path is a prefix of the
276 // request path, separated by a '/' boundary). This allows a single route registered at
277 // "/v1/batches" to match requests to "/v1/batches/abc123" or
278 // "/v1/batches/abc123/cancel".
279 let path_matches = path == route.path
280 || (path.starts_with(route.path)
281 && path.as_bytes().get(route.path.len()) == Some(&b'/'));
282 if path_matches && route.method.to_uppercase() == method {
283 if let Some(delay_ms) = route.delay_ms {
284 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
285 }
286 let status =
287 StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
288
289 if !route.stream_chunks.is_empty() {
290 // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
291 let mut sse = String::new();
292 for chunk in &route.stream_chunks {
293 sse.push_str("data: ");
294 sse.push_str(chunk);
295 sse.push_str("\n\n");
296 }
297 sse.push_str("data: [DONE]\n\n");
298
299 let mut builder = Response::builder()
300 .status(status)
301 .header("content-type", "text/event-stream")
302 .header("cache-control", "no-cache");
303 for (name, value) in &route.headers {
304 builder = builder.header(name, value);
305 }
306 return builder.body(Body::from(sse)).unwrap().into_response();
307 }
308
309 let mut builder =
310 Response::builder().status(status).header("content-type", "application/json");
311 for (name, value) in &route.headers {
312 builder = builder.header(name, value);
313 }
314 return builder.body(Body::from(route.body.clone())).unwrap().into_response();
315 }
316 }
317
318 // No matching route → 404.
319 Response::builder()
320 .status(StatusCode::NOT_FOUND)
321 .body(Body::from(format!("No mock route for {method} {path}")))
322 .unwrap()
323 .into_response()
324}
325"#
326}
327
328pub fn render_common_module() -> String {
342 hash::header(CommentStyle::DoubleSlash)
343 + r#"//
344// Auto-spawned mock server setup for e2e tests.
345// This module is auto-generated and should not be edited manually.
346
347use std::sync::OnceLock;
348
349static MOCK_SERVER_URL: OnceLock<String> = OnceLock::new();
350
351/// Get the mock server URL, spawning the server if not already running.
352///
353/// The server is spawned once per test process and reused by all tests.
354/// On first call, this function:
355/// - Spawns the `target/release/mock-server` binary
356/// - Reads `MOCK_SERVER_URL=http://...` from its stdout
357/// - Parses `MOCK_SERVERS={...}` JSON and sets env vars for per-fixture servers
358/// - Sets `MOCK_SERVER_URL` env var globally
359/// - Drains remaining stdout in a background thread
360///
361/// Subsequent calls return the cached URL without spawning again.
362pub fn mock_server_url() -> &'static str {
363 MOCK_SERVER_URL.get_or_init(|| {
364 let mock_server_bin = concat!(
365 env!("CARGO_MANIFEST_DIR"),
366 "/target/release/mock-server"
367 );
368 let fixtures_dir = concat!(
369 env!("CARGO_MANIFEST_DIR"),
370 "/../../fixtures"
371 );
372
373 // Spawn the mock-server binary with fixtures directory as argument.
374 let mut child = std::process::Command::new(mock_server_bin)
375 .arg(fixtures_dir)
376 .stdout(std::process::Stdio::piped())
377 .stdin(std::process::Stdio::piped())
378 .spawn()
379 .expect("Failed to spawn mock-server binary");
380
381 let stdout = child.stdout.take().expect("Failed to get stdout");
382 let stdin = child.stdin.take().expect("Failed to get stdin");
383
384 let mut url = String::new();
385 let mut line_buffer = String::new();
386 let mut line_count = 0;
387
388 // Read startup lines from the mock server.
389 // Expected: MOCK_SERVER_URL=http://... then MOCK_SERVERS={...json...}
390 // We read up to 16 lines total to accommodate noisy stderr before the markers.
391 use std::io::BufRead;
392 let mut reader = std::io::BufReader::new(stdout);
393
394 while line_count < 16 {
395 line_buffer.clear();
396 match reader.read_line(&mut line_buffer) {
397 Ok(0) => break, // EOF
398 Ok(_) => {
399 let line = line_buffer.trim();
400 if line.starts_with("MOCK_SERVER_URL=") {
401 url = line.strip_prefix("MOCK_SERVER_URL=")
402 .unwrap_or("")
403 .to_string();
404 } else if line.starts_with("MOCK_SERVERS=") {
405 let json_str = line.strip_prefix("MOCK_SERVERS=")
406 .unwrap_or("{}");
407 // Parse the JSON map and set env vars for each entry.
408 if let Ok(servers) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(json_str) {
409 for (fid, furl) in servers {
410 if let serde_json::Value::String(url_str) = furl {
411 let env_key = format!("MOCK_SERVER_{}", fid.to_uppercase());
412 std::env::set_var(&env_key, &url_str);
413 }
414 }
415 }
416 std::env::set_var("MOCK_SERVERS", json_str);
417 // We have seen both lines; stop reading.
418 break;
419 }
420 line_count += 1;
421 }
422 Err(_) => break,
423 }
424 }
425
426 // Set the main URL env var globally.
427 std::env::set_var("MOCK_SERVER_URL", &url);
428
429 // Drain remaining stdout in a background thread to prevent the server from blocking.
430 std::thread::spawn(move || {
431 let _ = std::io::copy(&mut reader.into_inner(), &mut std::io::sink());
432 });
433
434 // Close stdin to signal the mock-server that the parent is alive.
435 drop(stdin);
436
437 // Return the URL for this process.
438 url
439 }).as_str()
440}
441"#
442}
443
444pub fn render_mock_server_binary() -> String {
459 hash::header(CommentStyle::DoubleSlash)
460 + r#"//
461// Standalone mock HTTP server binary for cross-language e2e tests.
462// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
463//
464// Usage: mock-server [fixtures-dir]
465// fixtures-dir defaults to "../../fixtures"
466//
467// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
468// then optionally `MOCK_SERVERS={...}` with per-fixture URLs for host-root fixtures,
469// then blocks until stdin is closed (parent process exit triggers cleanup).
470
471use std::collections::HashMap;
472use std::io::{self, BufRead};
473use std::net::SocketAddr;
474use std::path::Path;
475use std::sync::Arc;
476
477use axum::Router;
478use axum::body::Body;
479use axum::extract::State;
480use axum::http::{Request, StatusCode};
481use axum::response::{IntoResponse, Response};
482use serde::Deserialize;
483use tokio::net::TcpListener;
484
485// ---------------------------------------------------------------------------
486// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
487// Supports both schemas:
488// liter-llm: mock_response: { status, body, stream_chunks }
489// spikard: http.expected_response: { status_code, body, headers }
490// ---------------------------------------------------------------------------
491
492#[derive(Debug, Deserialize)]
493struct MockResponse {
494 status: u16,
495 #[serde(default)]
496 body: Option<serde_json::Value>,
497 #[serde(default)]
498 stream_chunks: Option<Vec<serde_json::Value>>,
499 #[serde(default)]
500 headers: HashMap<String, String>,
501 /// Optional artificial delay (milliseconds) applied before sending the response.
502 /// Used by timeout-error fixtures to force the client request to time out.
503 #[serde(default)]
504 delay_ms: Option<u64>,
505}
506
507#[derive(Debug, Deserialize)]
508struct HttpExpectedResponse {
509 status_code: u16,
510 #[serde(default)]
511 body: Option<serde_json::Value>,
512 #[serde(default)]
513 headers: HashMap<String, String>,
514}
515
516#[derive(Debug, Deserialize)]
517struct HttpFixture {
518 expected_response: HttpExpectedResponse,
519}
520
521#[derive(Debug, Deserialize)]
522struct Fixture {
523 id: String,
524 #[serde(default)]
525 mock_response: Option<MockResponse>,
526 #[serde(default)]
527 http: Option<HttpFixture>,
528 /// Array-form fixture schema. `input.mock_responses[i] = { path?, status_code, headers, body_inline | body_file }`.
529 /// Used by kreuzcrawl-style fixtures that mock multiple URLs per fixture (e.g. a page +
530 /// `/robots.txt` + `/sitemap.xml`).
531 #[serde(default)]
532 input: Option<serde_json::Value>,
533}
534
535/// A single resolved mock response with its serving path and whether it is a host-root path.
536struct ResolvedRoute {
537 /// The namespaced path under which this route is registered in the shared server,
538 /// e.g. `/fixtures/robots_disallow_path` or `/fixtures/robots_disallow_path/assets/style.css`.
539 path: String,
540 /// The original fixture-declared path (before namespacing), e.g. `/robots.txt` or `/assets/style.css`.
541 original_path: String,
542 response: MockResponse,
543 /// Body bytes (pre-loaded from body_file or body_inline).
544 body_bytes: Vec<u8>,
545}
546
547/// Returns true for paths that the crawler fetches from the host root rather than
548/// under a fixture-namespaced prefix. These require a dedicated per-fixture listener.
549fn is_host_root_path(path: &str) -> bool {
550 path.starts_with("/robots") || path.starts_with("/sitemap")
551}
552
553impl Fixture {
554 /// Bridge both schemas into a unified MockResponse.
555 fn as_mock_response(&self) -> Option<MockResponse> {
556 if let Some(mock) = &self.mock_response {
557 return Some(MockResponse {
558 status: mock.status,
559 body: mock.body.clone(),
560 stream_chunks: mock.stream_chunks.clone(),
561 headers: mock.headers.clone(),
562 delay_ms: mock.delay_ms,
563 });
564 }
565 if let Some(http) = &self.http {
566 return Some(MockResponse {
567 status: http.expected_response.status_code,
568 body: http.expected_response.body.clone(),
569 stream_chunks: None,
570 headers: http.expected_response.headers.clone(),
571 delay_ms: None,
572 });
573 }
574 None
575 }
576
577 /// Resolve every mock response this fixture defines.
578 ///
579 /// Returns single-element output for the legacy `mock_response` / `http` schemas, and one
580 /// element per array entry for the kreuzcrawl-style `input.mock_responses` schema. For the
581 /// array schema, each element may declare its own `path` (defaulting to `/fixtures/{id}`),
582 /// and the body source can be either `body_inline` (string) or `body_file` (path relative
583 /// to the fixtures dir, loaded at startup).
584 ///
585 /// Paths that are NOT host-root paths (e.g. `/robots.txt`, `/sitemap.xml`) are namespaced
586 /// under `/fixtures/{id}` so that fixtures sharing common paths (like `/`, `/a`, `/b`) do
587 /// not collide in the shared route table.
588 fn as_routes(&self, fixtures_dir: &Path) -> Vec<ResolvedRoute> {
589 let mut routes = Vec::new();
590 let default_path = format!("/fixtures/{}", self.id);
591
592 if let Some(mock) = self.as_mock_response() {
593 let body_bytes = mock
594 .body
595 .as_ref()
596 .map(|b| match b {
597 serde_json::Value::String(s) => s.as_bytes().to_vec(),
598 other => serde_json::to_string(other).unwrap_or_default().into_bytes(),
599 })
600 .unwrap_or_default();
601 routes.push(ResolvedRoute {
602 path: default_path.clone(),
603 original_path: default_path.clone(),
604 response: mock,
605 body_bytes,
606 });
607 }
608
609 if let Some(input) = &self.input {
610 if let Some(arr) = input.get("mock_responses").and_then(|v| v.as_array()) {
611 for entry in arr {
612 let original_path = entry
613 .get("path")
614 .and_then(|v| v.as_str())
615 .unwrap_or("/")
616 .to_string();
617
618 // Namespace under /fixtures/<id> unless this is a host-root path.
619 let namespaced_path = if is_host_root_path(&original_path) {
620 original_path.clone()
621 } else if original_path == "/" {
622 format!("/fixtures/{}", self.id)
623 } else {
624 format!("/fixtures/{}{}", self.id, original_path)
625 };
626
627 let status: u16 = entry.get("status_code").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
628 let headers: HashMap<String, String> = entry
629 .get("headers")
630 .and_then(|v| v.as_object())
631 .map(|h| {
632 h.iter()
633 .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
634 .collect()
635 })
636 .unwrap_or_default();
637
638 // Load body bytes — use read() not read_to_string() to support binary files.
639 let body_bytes: Vec<u8> = if let Some(inline) = entry.get("body_inline").and_then(|v| v.as_str()) {
640 inline.as_bytes().to_vec()
641 } else if let Some(file) = entry.get("body_file").and_then(|v| v.as_str()) {
642 // body_file is resolved relative to `<fixtures>/responses/` first,
643 // falling back to `<fixtures>/` for projects that store body assets at
644 // the fixtures root rather than under a `responses/` subdir.
645 let candidates = [fixtures_dir.join("responses").join(file), fixtures_dir.join(file)];
646 let mut loaded = None;
647 for abs in &candidates {
648 if let Ok(bytes) = std::fs::read(abs) {
649 loaded = Some(bytes);
650 break;
651 }
652 }
653 match loaded {
654 Some(b) => b,
655 None => {
656 eprintln!(
657 "warning: cannot read body_file {} (tried {} and {})",
658 file,
659 candidates[0].display(),
660 candidates[1].display()
661 );
662 Vec::new()
663 }
664 }
665 } else {
666 Vec::new()
667 };
668
669 let delay_ms = entry.get("delay_ms").and_then(|v| v.as_u64());
670
671 routes.push(ResolvedRoute {
672 path: namespaced_path,
673 original_path,
674 response: MockResponse {
675 status,
676 body: None,
677 stream_chunks: None,
678 headers,
679 delay_ms,
680 },
681 body_bytes,
682 });
683 }
684 }
685 }
686
687 routes
688 }
689}
690
691// ---------------------------------------------------------------------------
692// Route table
693// ---------------------------------------------------------------------------
694
695#[derive(Clone, Debug)]
696struct MockRoute {
697 status: u16,
698 body: Vec<u8>,
699 stream_chunks: Vec<String>,
700 headers: Vec<(String, String)>,
701 /// Optional artificial delay applied before the handler returns. When set,
702 /// the handler `tokio::time::sleep`s for this many milliseconds before
703 /// constructing the response — used by timeout-error fixtures to force
704 /// client-side request timeouts.
705 delay_ms: Option<u64>,
706}
707
708type RouteTable = Arc<HashMap<String, MockRoute>>;
709
710// ---------------------------------------------------------------------------
711// Axum handler
712// ---------------------------------------------------------------------------
713
714async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
715 let path = req.uri().path().to_owned();
716
717 // Try exact match first
718 if let Some(route) = routes.get(&path) {
719 if let Some(delay_ms) = route.delay_ms {
720 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
721 }
722 return serve_route(route);
723 }
724
725 // Try prefix match: find a route that is a prefix of the request path
726 // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
727 for (route_path, route) in routes.iter() {
728 if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
729 if let Some(delay_ms) = route.delay_ms {
730 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
731 }
732 return serve_route(route);
733 }
734 }
735
736 Response::builder()
737 .status(StatusCode::NOT_FOUND)
738 .body(Body::from(format!("No mock route for {path}")))
739 .unwrap()
740 .into_response()
741}
742
743fn serve_route(route: &MockRoute) -> Response {
744 let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
745
746 if !route.stream_chunks.is_empty() {
747 let mut sse = String::new();
748 for chunk in &route.stream_chunks {
749 sse.push_str("data: ");
750 sse.push_str(chunk);
751 sse.push_str("\n\n");
752 }
753 sse.push_str("data: [DONE]\n\n");
754
755 let mut builder = Response::builder()
756 .status(status)
757 .header("content-type", "text/event-stream")
758 .header("cache-control", "no-cache");
759 for (name, value) in &route.headers {
760 builder = builder.header(name, value);
761 }
762 return builder.body(Body::from(sse)).unwrap().into_response();
763 }
764
765 // Only set the default content-type if the fixture does not override it.
766 // Inspect the first non-whitespace byte to detect JSON vs binary vs plain text.
767 let has_content_type = route.headers.iter().any(|(k, _)| k.to_lowercase() == "content-type");
768 let mut builder = Response::builder().status(status);
769 if !has_content_type {
770 let first_nonws = route.body.iter().find(|&&b| b != b' ' && b != b'\t' && b != b'\n' && b != b'\r');
771 let default_ct = match first_nonws {
772 Some(&b'{') | Some(&b'[') => "application/json",
773 _ => "text/plain",
774 };
775 builder = builder.header("content-type", default_ct);
776 }
777 for (name, value) in &route.headers {
778 // Skip content-encoding headers — the mock server returns uncompressed bodies.
779 // Sending a content-encoding without actually encoding the body would cause
780 // clients to fail decompression.
781 if name.to_lowercase() == "content-encoding" {
782 continue;
783 }
784 // The <<absent>> sentinel means this header must NOT be present in the
785 // real server response — do not emit it from the mock server either.
786 if value == "<<absent>>" {
787 continue;
788 }
789 // Replace the <<uuid>> sentinel with a real UUID v4 so clients can
790 // assert the header value matches the UUID pattern.
791 if value == "<<uuid>>" {
792 let uuid = format!(
793 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
794 rand_u32(),
795 rand_u16(),
796 rand_u16() & 0x0fff,
797 (rand_u16() & 0x3fff) | 0x8000,
798 rand_u48(),
799 );
800 builder = builder.header(name, uuid);
801 continue;
802 }
803 builder = builder.header(name, value);
804 }
805 builder.body(Body::from(route.body.clone())).unwrap().into_response()
806}
807
808/// Generate a pseudo-random u32 using the current time nanoseconds.
809fn rand_u32() -> u32 {
810 use std::time::{SystemTime, UNIX_EPOCH};
811 let ns = SystemTime::now()
812 .duration_since(UNIX_EPOCH)
813 .map(|d| d.subsec_nanos())
814 .unwrap_or(0);
815 ns ^ (ns.wrapping_shl(13)) ^ (ns.wrapping_shr(17))
816}
817
818fn rand_u16() -> u16 {
819 (rand_u32() & 0xffff) as u16
820}
821
822fn rand_u48() -> u64 {
823 ((rand_u32() as u64) << 16) | (rand_u16() as u64)
824}
825
826// ---------------------------------------------------------------------------
827// Fixture loading
828// ---------------------------------------------------------------------------
829
830/// Intermediate fixture-loading result: shared route table plus per-fixture host-root data.
831struct LoadedRoutes {
832 /// Routes namespaced under /fixtures/<id> for the shared listener.
833 shared: HashMap<String, MockRoute>,
834 /// For each fixture that has host-root routes: fixture_id → route table at host root.
835 per_fixture: HashMap<String, HashMap<String, MockRoute>>,
836}
837
838fn load_routes(fixtures_dir: &Path) -> LoadedRoutes {
839 let mut shared = HashMap::new();
840 let mut per_fixture: HashMap<String, HashMap<String, MockRoute>> = HashMap::new();
841 load_routes_recursive(fixtures_dir, fixtures_dir, &mut shared, &mut per_fixture);
842 LoadedRoutes { shared, per_fixture }
843}
844
845fn load_routes_recursive(
846 dir: &Path,
847 fixtures_root: &Path,
848 shared: &mut HashMap<String, MockRoute>,
849 per_fixture: &mut HashMap<String, HashMap<String, MockRoute>>,
850) {
851 let entries = match std::fs::read_dir(dir) {
852 Ok(e) => e,
853 Err(err) => {
854 eprintln!("warning: cannot read directory {}: {err}", dir.display());
855 return;
856 }
857 };
858
859 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
860 paths.sort();
861
862 for path in paths {
863 if path.is_dir() {
864 load_routes_recursive(&path, fixtures_root, shared, per_fixture);
865 } else if path.extension().is_some_and(|ext| ext == "json") {
866 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
867 if filename == "schema.json" || filename.starts_with('_') {
868 continue;
869 }
870 let content = match std::fs::read_to_string(&path) {
871 Ok(c) => c,
872 Err(err) => {
873 eprintln!("warning: cannot read {}: {err}", path.display());
874 continue;
875 }
876 };
877 let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
878 match serde_json::from_str(&content) {
879 Ok(v) => v,
880 Err(err) => {
881 eprintln!("warning: cannot parse {}: {err}", path.display());
882 continue;
883 }
884 }
885 } else {
886 match serde_json::from_str::<Fixture>(&content) {
887 Ok(f) => vec![f],
888 Err(err) => {
889 eprintln!("warning: cannot parse {}: {err}", path.display());
890 continue;
891 }
892 }
893 };
894
895 for fixture in fixtures {
896 let resolved_routes = fixture.as_routes(fixtures_root);
897 // A fixture needs host-root routing if either:
898 // 1. it serves a path the crawler fetches at host root (/robots*, /sitemap*), OR
899 // 2. it returns a 3xx Location header pointing to a host-root path inside the
900 // same fixture (the engine resolves the Location against the host, not the
901 // /fixtures/<id>/ namespace, so host-root serving is required for the
902 // follow-up GET to hit the correct route).
903 let has_intra_fixture_redirect = resolved_routes.iter().any(|r| {
904 // 3xx with relative Location header
905 let location_redirect = (300..400).contains(&r.response.status)
906 && r.response.headers.iter().any(|(name, value)| {
907 name.eq_ignore_ascii_case("location") && value.starts_with('/')
908 });
909 // Refresh header with url=/...
910 let refresh_redirect = r.response.headers.iter().any(|(name, value)| {
911 if !name.eq_ignore_ascii_case("refresh") {
912 return false;
913 }
914 let lower = value.to_ascii_lowercase();
915 lower
916 .find("url=")
917 .map(|idx| value[idx + 4..].trim_start().starts_with('/'))
918 .unwrap_or(false)
919 });
920 // HTML meta-refresh tag pointing to /...
921 let body_lower_lossy = String::from_utf8_lossy(&r.body_bytes).to_ascii_lowercase();
922 let meta_refresh = body_lower_lossy
923 .split("http-equiv=\"refresh\"")
924 .nth(1)
925 .and_then(|s| s.split("content=").nth(1))
926 .map(|s| {
927 let trimmed = s.trim_start_matches(['"', '\'']);
928 trimmed.contains("url=/")
929 })
930 .unwrap_or(false);
931 location_redirect || refresh_redirect || meta_refresh
932 });
933 let has_host_root = has_intra_fixture_redirect
934 || resolved_routes.iter().any(|r| is_host_root_path(&r.original_path));
935
936 for resolved in resolved_routes {
937 let stream_chunks = resolved.response
938 .stream_chunks
939 .unwrap_or_default()
940 .into_iter()
941 .map(|c| match c {
942 serde_json::Value::String(s) => s,
943 other => serde_json::to_string(&other).unwrap_or_default(),
944 })
945 .collect();
946 let mut headers: Vec<(String, String)> = resolved.response.headers.into_iter().collect();
947 headers.sort_by(|a, b| a.0.cmp(&b.0));
948
949 let mock_route = MockRoute {
950 status: resolved.response.status,
951 body: resolved.body_bytes,
952 stream_chunks,
953 headers,
954 delay_ms: resolved.response.delay_ms,
955 };
956
957 // Always insert into the shared namespaced table.
958 shared.insert(resolved.path.clone(), mock_route.clone());
959
960 // For fixtures with host-root routes, also build a per-fixture table
961 // where routes are mounted at their original (un-namespaced) paths.
962 if has_host_root {
963 per_fixture
964 .entry(fixture.id.clone())
965 .or_default()
966 .insert(resolved.original_path.clone(), mock_route);
967 }
968 }
969 }
970 }
971 }
972}
973
974// ---------------------------------------------------------------------------
975// Entry point
976// ---------------------------------------------------------------------------
977
978#[tokio::main]
979async fn main() {
980 let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
981 let fixtures_dir = Path::new(&fixtures_dir_arg);
982
983 let loaded = load_routes(fixtures_dir);
984 eprintln!("mock-server: loaded {} shared routes from {}", loaded.shared.len(), fixtures_dir.display());
985
986 // Shared namespaced server.
987 let shared_table: RouteTable = Arc::new(loaded.shared);
988 let shared_app = Router::new().fallback(handle_request).with_state(shared_table);
989
990 let shared_listener = TcpListener::bind("127.0.0.1:0")
991 .await
992 .expect("mock-server: failed to bind shared port");
993 let shared_addr: SocketAddr = shared_listener.local_addr().expect("mock-server: failed to get shared local addr");
994
995 // Per-fixture listeners for host-root routes (robots.txt, sitemap.xml, etc.).
996 // Sorted by fixture_id for deterministic output.
997 let mut fixture_ids: Vec<String> = loaded.per_fixture.keys().cloned().collect();
998 fixture_ids.sort();
999
1000 let mut fixture_urls: HashMap<String, String> = HashMap::new();
1001 for fixture_id in &fixture_ids {
1002 let routes = loaded.per_fixture[fixture_id].clone();
1003 eprintln!("mock-server: fixture {} has {} host-root routes", fixture_id, routes.len());
1004 let table: RouteTable = Arc::new(routes);
1005 let app = Router::new().fallback(handle_request).with_state(table);
1006 let listener = TcpListener::bind("127.0.0.1:0")
1007 .await
1008 .expect("mock-server: failed to bind per-fixture port");
1009 let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get per-fixture local addr");
1010 fixture_urls.insert(fixture_id.clone(), format!("http://{addr}"));
1011 tokio::spawn(async move {
1012 axum::serve(listener, app).await.expect("mock-server: per-fixture server error");
1013 });
1014 }
1015
1016 // Print the shared URL so the parent process can read it.
1017 println!("MOCK_SERVER_URL=http://{shared_addr}");
1018
1019 // Always print MOCK_SERVERS=... (empty `{}` when there are no host-root
1020 // fixtures) so parent parsers — which read until they see this sentinel
1021 // line — never block on a readline that never comes.
1022 let mut sorted_pairs: Vec<(&String, &String)> = fixture_urls.iter().collect();
1023 sorted_pairs.sort_by_key(|(k, _)| k.as_str());
1024 let json_entries: Vec<String> = sorted_pairs
1025 .iter()
1026 .map(|(k, v)| format!("\"{}\":\"{}\"", k, v))
1027 .collect();
1028 println!("MOCK_SERVERS={{{}}}", json_entries.join(","));
1029
1030 // Flush stdout explicitly so the parent does not block waiting.
1031 use std::io::Write;
1032 std::io::stdout().flush().expect("mock-server: failed to flush stdout");
1033
1034 // Spawn the shared server in the background.
1035 tokio::spawn(async move {
1036 axum::serve(shared_listener, shared_app).await.expect("mock-server: shared server error");
1037 });
1038
1039 // Block until stdin is closed — the parent process controls lifetime.
1040 let stdin = io::stdin();
1041 let mut lines = stdin.lock().lines();
1042 while lines.next().is_some() {}
1043}
1044"#
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049 use super::*;
1050
1051 #[test]
1052 fn render_mock_server_module_contains_struct_definition() {
1053 let out = render_mock_server_module();
1054 assert!(out.contains("pub struct MockRoute"));
1055 assert!(out.contains("pub struct MockServer"));
1056 }
1057
1058 #[test]
1059 fn render_mock_server_binary_contains_main() {
1060 let out = render_mock_server_binary();
1061 assert!(out.contains("async fn main()"));
1062 assert!(out.contains("MOCK_SERVER_URL=http://"));
1063 }
1064
1065 #[test]
1066 fn render_common_module_has_expected_symbols() {
1067 let src = render_common_module();
1068 assert!(src.contains("pub fn mock_server_url"), "missing mock_server_url");
1069 assert!(src.contains("OnceLock"), "missing OnceLock");
1070 assert!(src.contains("MOCK_SERVER_URL"), "missing MOCK_SERVER_URL");
1071 assert!(src.contains("MOCK_SERVERS"), "missing MOCK_SERVERS");
1072 assert!(src.contains("serde_json"), "missing serde_json parsing");
1073 }
1074}