Skip to main content

alef_e2e/codegen/rust/
mock_server.rs

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