Skip to main content

specloom_core/
figma_client.rs

1pub mod normalizer;
2
3use serde_json::{Value, json};
4
5pub const RAW_SNAPSHOT_SCHEMA_VERSION: &str = "1.0";
6pub const FIGMA_API_VERSION: &str = "v1";
7pub const DEFAULT_FIGMA_API_BASE_URL: &str = "https://api.figma.com";
8
9#[derive(Debug, thiserror::Error)]
10pub enum FetchClientError {
11    #[error("invalid fetch request: {0}")]
12    InvalidRequest(String),
13    #[error("invalid fixture json: {0}")]
14    InvalidFixtureJson(#[from] serde_json::Error),
15    #[error("figma api unauthorized")]
16    Unauthorized,
17    #[error("figma api returned non-success status {status}: {message}")]
18    HttpStatus { status: u16, message: String },
19    #[error("invalid figma api response: {0}")]
20    InvalidApiResponse(String),
21    #[error("http transport error: {0}")]
22    HttpTransport(String),
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct FetchNodesRequest {
28    pub file_key: String,
29    pub node_id: String,
30}
31
32impl FetchNodesRequest {
33    pub fn new(file_key: String, node_id: String) -> Result<Self, FetchClientError> {
34        let file_key = file_key.trim().to_string();
35        if file_key.is_empty() {
36            return Err(FetchClientError::InvalidRequest(
37                "file_key is required".to_string(),
38            ));
39        }
40
41        let node_id = node_id.trim().to_string();
42        if node_id.is_empty() {
43            return Err(FetchClientError::InvalidRequest(
44                "node_id is required".to_string(),
45            ));
46        }
47
48        Ok(Self { file_key, node_id })
49    }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct LiveFetchRequest {
54    pub fetch: FetchNodesRequest,
55    pub figma_token: String,
56    pub api_base_url: Option<String>,
57}
58
59impl LiveFetchRequest {
60    pub fn new(
61        file_key: String,
62        node_id: String,
63        figma_token: String,
64        api_base_url: Option<String>,
65    ) -> Result<Self, FetchClientError> {
66        let fetch = FetchNodesRequest::new(file_key, node_id)?;
67
68        let figma_token = figma_token.trim().to_string();
69        if figma_token.is_empty() {
70            return Err(FetchClientError::InvalidRequest(
71                "figma_token is required for live fetch".to_string(),
72            ));
73        }
74
75        let api_base_url = api_base_url
76            .map(|value| value.trim().to_string())
77            .filter(|value| !value.is_empty());
78
79        Ok(Self {
80            fetch,
81            figma_token,
82            api_base_url,
83        })
84    }
85
86    pub fn api_base_url(&self) -> &str {
87        self.api_base_url
88            .as_deref()
89            .unwrap_or(DEFAULT_FIGMA_API_BASE_URL)
90    }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct LiveScreenshotRequest {
95    pub fetch: FetchNodesRequest,
96    pub figma_token: String,
97    pub api_base_url: Option<String>,
98}
99
100impl LiveScreenshotRequest {
101    pub fn new(
102        file_key: String,
103        node_id: String,
104        figma_token: String,
105        api_base_url: Option<String>,
106    ) -> Result<Self, FetchClientError> {
107        let fetch = FetchNodesRequest::new(file_key, node_id)?;
108
109        let figma_token = figma_token.trim().to_string();
110        if figma_token.is_empty() {
111            return Err(FetchClientError::InvalidRequest(
112                "figma_token is required for screenshot fetch".to_string(),
113            ));
114        }
115
116        let api_base_url = api_base_url
117            .map(|value| value.trim().to_string())
118            .filter(|value| !value.is_empty());
119
120        Ok(Self {
121            fetch,
122            figma_token,
123            api_base_url,
124        })
125    }
126
127    pub fn api_base_url(&self) -> &str {
128        self.api_base_url
129            .as_deref()
130            .unwrap_or(DEFAULT_FIGMA_API_BASE_URL)
131    }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct NodeScreenshot {
137    pub node_id: String,
138    pub image_url: String,
139    pub format: String,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
143#[serde(deny_unknown_fields)]
144pub struct RawSnapshotSource {
145    pub file_key: String,
146    pub node_id: String,
147    pub figma_api_version: String,
148}
149
150#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct RawFigmaSnapshot {
153    pub snapshot_version: String,
154    pub source: RawSnapshotSource,
155    pub payload: Value,
156}
157
158pub fn fetch_snapshot_from_fixture(
159    request: &FetchNodesRequest,
160    fixture_json: &str,
161) -> Result<RawFigmaSnapshot, FetchClientError> {
162    let payload: Value = serde_json::from_str(fixture_json)?;
163    Ok(RawFigmaSnapshot {
164        snapshot_version: RAW_SNAPSHOT_SCHEMA_VERSION.to_string(),
165        source: RawSnapshotSource {
166            file_key: request.file_key.clone(),
167            node_id: request.node_id.clone(),
168            figma_api_version: FIGMA_API_VERSION.to_string(),
169        },
170        payload,
171    })
172}
173
174pub fn fetch_snapshot_live(
175    request: &LiveFetchRequest,
176) -> Result<RawFigmaSnapshot, FetchClientError> {
177    fetch_snapshot_live_with_base_url(
178        &request.fetch,
179        request.figma_token.as_str(),
180        request.api_base_url(),
181    )
182}
183
184pub fn fetch_node_screenshot_live(
185    request: &LiveScreenshotRequest,
186) -> Result<NodeScreenshot, FetchClientError> {
187    fetch_node_screenshot_live_with_base_url(
188        &request.fetch,
189        request.figma_token.as_str(),
190        request.api_base_url(),
191    )
192}
193
194pub fn fetch_snapshot_live_with_base_url(
195    request: &FetchNodesRequest,
196    figma_token: &str,
197    api_base_url: &str,
198) -> Result<RawFigmaSnapshot, FetchClientError> {
199    let figma_token = figma_token.trim();
200    if figma_token.is_empty() {
201        return Err(FetchClientError::InvalidRequest(
202            "figma_token is required for live fetch".to_string(),
203        ));
204    }
205    let api_base_url = api_base_url.trim();
206    if api_base_url.is_empty() {
207        return Err(FetchClientError::InvalidRequest(
208            "api_base_url is required for live fetch".to_string(),
209        ));
210    }
211
212    let api_url = format!(
213        "{}/v1/files/{}/nodes",
214        api_base_url.trim_end_matches('/'),
215        request.file_key
216    );
217
218    let response = reqwest::blocking::Client::new()
219        .get(api_url)
220        .header("X-Figma-Token", figma_token)
221        .query(&[("ids", request.node_id.as_str())])
222        .send()
223        .map_err(|err| FetchClientError::HttpTransport(err.to_string()))?;
224
225    let status = response.status();
226    if status == reqwest::StatusCode::UNAUTHORIZED {
227        return Err(FetchClientError::Unauthorized);
228    }
229    if !status.is_success() {
230        let body = response
231            .text()
232            .unwrap_or_else(|_| "response body unavailable".to_string());
233        return Err(FetchClientError::HttpStatus {
234            status: status.as_u16(),
235            message: body,
236        });
237    }
238
239    let payload = response
240        .json::<Value>()
241        .map_err(|err| FetchClientError::InvalidApiResponse(err.to_string()))?;
242    build_snapshot_from_live_nodes_payload(request, payload)
243}
244
245pub fn fetch_node_screenshot_live_with_base_url(
246    request: &FetchNodesRequest,
247    figma_token: &str,
248    api_base_url: &str,
249) -> Result<NodeScreenshot, FetchClientError> {
250    let figma_token = figma_token.trim();
251    if figma_token.is_empty() {
252        return Err(FetchClientError::InvalidRequest(
253            "figma_token is required for screenshot fetch".to_string(),
254        ));
255    }
256    let api_base_url = api_base_url.trim();
257    if api_base_url.is_empty() {
258        return Err(FetchClientError::InvalidRequest(
259            "api_base_url is required for screenshot fetch".to_string(),
260        ));
261    }
262
263    let api_url = format!(
264        "{}/v1/images/{}",
265        api_base_url.trim_end_matches('/'),
266        request.file_key
267    );
268
269    let response = reqwest::blocking::Client::new()
270        .get(api_url)
271        .header("X-Figma-Token", figma_token)
272        .query(&[("ids", request.node_id.as_str()), ("format", "png")])
273        .send()
274        .map_err(|err| FetchClientError::HttpTransport(err.to_string()))?;
275
276    let status = response.status();
277    if status == reqwest::StatusCode::UNAUTHORIZED {
278        return Err(FetchClientError::Unauthorized);
279    }
280    if !status.is_success() {
281        let body = response
282            .text()
283            .unwrap_or_else(|_| "response body unavailable".to_string());
284        return Err(FetchClientError::HttpStatus {
285            status: status.as_u16(),
286            message: body,
287        });
288    }
289
290    let payload = response
291        .json::<Value>()
292        .map_err(|err| FetchClientError::InvalidApiResponse(err.to_string()))?;
293    build_node_screenshot_from_payload(request, payload)
294}
295
296fn build_snapshot_from_live_nodes_payload(
297    request: &FetchNodesRequest,
298    payload: Value,
299) -> Result<RawFigmaSnapshot, FetchClientError> {
300    let document = payload
301        .get("nodes")
302        .and_then(Value::as_object)
303        .and_then(|nodes| nodes.get(request.node_id.as_str()))
304        .and_then(Value::as_object)
305        .and_then(|node| node.get("document"))
306        .cloned()
307        .ok_or_else(|| {
308            FetchClientError::InvalidApiResponse(format!(
309                "missing nodes.{}.document in figma response",
310                request.node_id
311            ))
312        })?;
313
314    Ok(RawFigmaSnapshot {
315        snapshot_version: RAW_SNAPSHOT_SCHEMA_VERSION.to_string(),
316        source: RawSnapshotSource {
317            file_key: request.file_key.clone(),
318            node_id: request.node_id.clone(),
319            figma_api_version: FIGMA_API_VERSION.to_string(),
320        },
321        payload: json!({
322            "document": document
323        }),
324    })
325}
326
327fn build_node_screenshot_from_payload(
328    request: &FetchNodesRequest,
329    payload: Value,
330) -> Result<NodeScreenshot, FetchClientError> {
331    let image_url = payload
332        .get("images")
333        .and_then(Value::as_object)
334        .and_then(|images| images.get(request.node_id.as_str()))
335        .and_then(Value::as_str)
336        .ok_or_else(|| {
337            FetchClientError::InvalidApiResponse(format!(
338                "missing images.{} in figma response",
339                request.node_id
340            ))
341        })?;
342
343    Ok(NodeScreenshot {
344        node_id: request.node_id.clone(),
345        image_url: image_url.to_string(),
346        format: "png".to_string(),
347    })
348}
349
350#[cfg(test)]
351mod tests {
352    use serde_json::json;
353    use std::io::{Read, Write};
354
355    #[test]
356    fn fetch_nodes_request_rejects_missing_file_key() {
357        let err = super::FetchNodesRequest::new("".to_string(), "123:456".to_string())
358            .expect_err("empty file key should be rejected");
359        assert_eq!(
360            err.to_string(),
361            "invalid fetch request: file_key is required"
362        );
363    }
364
365    #[test]
366    fn fetch_snapshot_from_fixture_preserves_source_and_payload() {
367        let request = super::FetchNodesRequest::new("abc123".to_string(), "123:456".to_string())
368            .expect("request should be valid");
369
370        let fixture = r#"{
371            "document": {
372                "id": "123:456",
373                "name": "Root Frame"
374            }
375        }"#;
376
377        let snapshot = super::fetch_snapshot_from_fixture(&request, fixture)
378            .expect("fixture payload should parse");
379
380        assert_eq!(
381            snapshot.snapshot_version,
382            super::RAW_SNAPSHOT_SCHEMA_VERSION
383        );
384        assert_eq!(snapshot.source.file_key, "abc123");
385        assert_eq!(snapshot.source.node_id, "123:456");
386        assert_eq!(snapshot.source.figma_api_version, super::FIGMA_API_VERSION);
387        assert_eq!(
388            snapshot.payload,
389            json!({
390                "document": {
391                    "id": "123:456",
392                    "name": "Root Frame"
393                }
394            })
395        );
396    }
397
398    #[test]
399    fn fetch_snapshot_from_fixture_reports_invalid_json() {
400        let request = super::FetchNodesRequest::new("abc123".to_string(), "123:456".to_string())
401            .expect("request should be valid");
402
403        let err = super::fetch_snapshot_from_fixture(&request, "{")
404            .expect_err("malformed fixture should fail");
405        assert!(err.to_string().starts_with("invalid fixture json:"));
406    }
407
408    #[test]
409    fn raw_snapshot_contract_round_trip() {
410        let request = super::FetchNodesRequest::new("abc123".to_string(), "123:456".to_string())
411            .expect("request should be valid");
412        let snapshot = super::fetch_snapshot_from_fixture(
413            &request,
414            r#"{"document":{"id":"123:456","name":"Root Frame"}}"#,
415        )
416        .expect("fixture payload should parse");
417
418        let encoded = serde_json::to_string(&snapshot).expect("snapshot should serialize");
419        let decoded: super::RawFigmaSnapshot =
420            serde_json::from_str(&encoded).expect("snapshot should deserialize");
421
422        assert_eq!(decoded, snapshot);
423    }
424
425    #[test]
426    fn live_fetch_request_rejects_missing_figma_token() {
427        let err = super::LiveFetchRequest::new(
428            "abc123".to_string(),
429            "123:456".to_string(),
430            "".to_string(),
431            None,
432        )
433        .expect_err("empty figma token should be rejected");
434
435        assert_eq!(
436            err.to_string(),
437            "invalid fetch request: figma_token is required for live fetch"
438        );
439    }
440
441    #[test]
442    fn live_fetch_request_allows_explicit_api_base_url_override() {
443        let request = super::LiveFetchRequest::new(
444            "abc123".to_string(),
445            "123:456".to_string(),
446            "secret-token".to_string(),
447            Some("http://127.0.0.1:9999".to_string()),
448        )
449        .expect("live fetch request should be valid");
450
451        assert_eq!(request.fetch.file_key, "abc123");
452        assert_eq!(request.fetch.node_id, "123:456");
453        assert_eq!(request.figma_token, "secret-token");
454        assert_eq!(
455            request.api_base_url,
456            Some("http://127.0.0.1:9999".to_string())
457        );
458    }
459
460    #[test]
461    fn live_fetch_request_uses_default_figma_api_base_url() {
462        let request = super::LiveFetchRequest::new(
463            "abc123".to_string(),
464            "123:456".to_string(),
465            "secret-token".to_string(),
466            None,
467        )
468        .expect("live fetch request should be valid");
469
470        assert_eq!(request.api_base_url(), super::DEFAULT_FIGMA_API_BASE_URL);
471    }
472
473    #[test]
474    fn fetch_client_error_contract_includes_live_transport_variants() {
475        let unauthorized = super::FetchClientError::Unauthorized;
476        assert_eq!(unauthorized.to_string(), "figma api unauthorized");
477
478        let http_status = super::FetchClientError::HttpStatus {
479            status: 404,
480            message: "Not Found".to_string(),
481        };
482        assert_eq!(
483            http_status.to_string(),
484            "figma api returned non-success status 404: Not Found"
485        );
486    }
487
488    #[test]
489    fn build_snapshot_from_live_nodes_payload_extracts_requested_document() {
490        let request = super::FetchNodesRequest::new("abc123".to_string(), "123:456".to_string())
491            .expect("request should be valid");
492        let payload = serde_json::json!({
493            "nodes": {
494                "123:456": {
495                    "document": {
496                        "id": "123:456",
497                        "name": "Live Root"
498                    }
499                }
500            }
501        });
502
503        let snapshot = super::build_snapshot_from_live_nodes_payload(&request, payload)
504            .expect("valid payload");
505
506        assert_eq!(snapshot.source.file_key, "abc123");
507        assert_eq!(snapshot.source.node_id, "123:456");
508        assert_eq!(
509            snapshot.payload,
510            serde_json::json!({
511                "document": {
512                    "id": "123:456",
513                    "name": "Live Root"
514                }
515            })
516        );
517    }
518
519    #[test]
520    fn build_snapshot_from_live_nodes_payload_requires_document_for_node() {
521        let request = super::FetchNodesRequest::new("abc123".to_string(), "123:456".to_string())
522            .expect("request should be valid");
523        let payload = serde_json::json!({
524            "nodes": {
525                "123:456": {}
526            }
527        });
528
529        let err = super::build_snapshot_from_live_nodes_payload(&request, payload)
530            .expect_err("payload without document should fail");
531        assert_eq!(
532            err.to_string(),
533            "invalid figma api response: missing nodes.123:456.document in figma response"
534        );
535    }
536
537    #[test]
538    fn fetch_snapshot_live_rejects_missing_figma_token() {
539        let request = super::FetchNodesRequest::new("abc123".to_string(), "123:456".to_string())
540            .expect("request should be valid");
541        let err = super::fetch_snapshot_live_with_base_url(&request, "", "http://127.0.0.1:9")
542            .expect_err("empty token should fail");
543
544        assert_eq!(
545            err.to_string(),
546            "invalid fetch request: figma_token is required for live fetch"
547        );
548    }
549
550    #[test]
551    fn fetch_snapshot_live_with_base_url_sends_auth_header_and_maps_success() {
552        let (base_url, request_rx, server_thread) = match start_single_response_server(
553            "200 OK",
554            r#"{
555                "nodes": {
556                    "123:456": {
557                        "document": {
558                            "id": "123:456",
559                            "name": "Live Root"
560                        }
561                    }
562                }
563            }"#,
564        ) {
565            Ok(server) => server,
566            Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
567                eprintln!("skipping live transport test: local socket bind not permitted");
568                return;
569            }
570            Err(err) => panic!("mock server should bind: {err}"),
571        };
572        let request = super::FetchNodesRequest::new("abc123".to_string(), "123:456".to_string())
573            .expect("request should be valid");
574
575        let snapshot =
576            super::fetch_snapshot_live_with_base_url(&request, "secret-token", &base_url)
577                .expect("live fetch should succeed");
578        let raw_request = request_rx
579            .recv_timeout(std::time::Duration::from_secs(2))
580            .expect("mock server should receive request");
581        server_thread.join().expect("server thread should finish");
582
583        let lower_request = raw_request.to_ascii_lowercase();
584        assert!(raw_request.starts_with("GET /v1/files/abc123/nodes?ids=123%3A456 HTTP/1.1"));
585        assert!(lower_request.contains("x-figma-token: secret-token"));
586        assert_eq!(
587            snapshot.payload,
588            serde_json::json!({
589                "document": {
590                    "id": "123:456",
591                    "name": "Live Root"
592                }
593            })
594        );
595    }
596
597    #[test]
598    fn fetch_snapshot_live_maps_unauthorized_status() {
599        let (base_url, _request_rx, server_thread) =
600            match start_single_response_server("401 Unauthorized", "Unauthorized") {
601                Ok(server) => server,
602                Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
603                    eprintln!("skipping live transport test: local socket bind not permitted");
604                    return;
605                }
606                Err(err) => panic!("mock server should bind: {err}"),
607            };
608        let request = super::FetchNodesRequest::new("abc123".to_string(), "123:456".to_string())
609            .expect("request should be valid");
610
611        let err = super::fetch_snapshot_live_with_base_url(&request, "secret-token", &base_url)
612            .expect_err("unauthorized response should fail");
613        server_thread.join().expect("server thread should finish");
614
615        assert_eq!(err.to_string(), "figma api unauthorized");
616    }
617
618    #[test]
619    fn fetch_snapshot_live_maps_non_success_status_with_body() {
620        let (base_url, _request_rx, server_thread) =
621            match start_single_response_server("404 Not Found", "No file") {
622                Ok(server) => server,
623                Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
624                    eprintln!("skipping live transport test: local socket bind not permitted");
625                    return;
626                }
627                Err(err) => panic!("mock server should bind: {err}"),
628            };
629        let request = super::FetchNodesRequest::new("abc123".to_string(), "123:456".to_string())
630            .expect("request should be valid");
631
632        let err = super::fetch_snapshot_live_with_base_url(&request, "secret-token", &base_url)
633            .expect_err("404 response should fail");
634        server_thread.join().expect("server thread should finish");
635
636        assert_eq!(
637            err.to_string(),
638            "figma api returned non-success status 404: No file"
639        );
640    }
641
642    #[test]
643    fn fetch_node_screenshot_live_with_base_url_requests_images_endpoint() {
644        let (base_url, request_rx, server_thread) = match start_single_response_server(
645            "200 OK",
646            r#"{
647                "images": {
648                    "123:456": "https://cdn.example.com/image.png"
649                }
650            }"#,
651        ) {
652            Ok(server) => server,
653            Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
654                eprintln!("skipping live transport test: local socket bind not permitted");
655                return;
656            }
657            Err(err) => panic!("mock server should bind: {err}"),
658        };
659        let request = super::FetchNodesRequest::new("abc123".to_string(), "123:456".to_string())
660            .expect("request should be valid");
661
662        let screenshot =
663            super::fetch_node_screenshot_live_with_base_url(&request, "secret-token", &base_url)
664                .expect("screenshot fetch should succeed");
665        let raw_request = request_rx
666            .recv_timeout(std::time::Duration::from_secs(2))
667            .expect("mock server should receive request");
668        server_thread.join().expect("server thread should finish");
669
670        let lower_request = raw_request.to_ascii_lowercase();
671        assert!(raw_request.starts_with("GET /v1/images/abc123?ids=123%3A456&format=png HTTP/1.1"));
672        assert!(lower_request.contains("x-figma-token: secret-token"));
673        assert_eq!(screenshot.node_id, "123:456");
674        assert_eq!(screenshot.image_url, "https://cdn.example.com/image.png");
675        assert_eq!(screenshot.format, "png");
676    }
677
678    #[test]
679    fn fetch_node_screenshot_live_with_base_url_reports_missing_image_ref() {
680        let (base_url, _request_rx, server_thread) =
681            match start_single_response_server("200 OK", r#"{"images":{}}"#) {
682                Ok(server) => server,
683                Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
684                    eprintln!("skipping live transport test: local socket bind not permitted");
685                    return;
686                }
687                Err(err) => panic!("mock server should bind: {err}"),
688            };
689        let request = super::FetchNodesRequest::new("abc123".to_string(), "123:456".to_string())
690            .expect("request should be valid");
691
692        let err =
693            super::fetch_node_screenshot_live_with_base_url(&request, "secret-token", &base_url)
694                .expect_err("missing image ref should fail");
695        server_thread.join().expect("server thread should finish");
696
697        assert_eq!(
698            err.to_string(),
699            "invalid figma api response: missing images.123:456 in figma response"
700        );
701    }
702
703    fn start_single_response_server(
704        status_line: &str,
705        body: &str,
706    ) -> Result<
707        (
708            String,
709            std::sync::mpsc::Receiver<String>,
710            std::thread::JoinHandle<()>,
711        ),
712        std::io::Error,
713    > {
714        let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
715        let address = listener
716            .local_addr()
717            .expect("mock server should expose local address");
718        let (request_tx, request_rx) = std::sync::mpsc::channel::<String>();
719        let status_line = status_line.to_string();
720        let body = body.to_string();
721
722        let server_thread = std::thread::spawn(move || {
723            let (mut stream, _) = listener
724                .accept()
725                .expect("mock server should accept one request");
726            stream
727                .set_read_timeout(Some(std::time::Duration::from_secs(2)))
728                .expect("mock server should set read timeout");
729
730            let mut request_bytes = Vec::new();
731            let mut buffer = [0_u8; 4096];
732            loop {
733                let bytes_read = stream
734                    .read(&mut buffer)
735                    .expect("mock server should read request bytes");
736                if bytes_read == 0 {
737                    break;
738                }
739                request_bytes.extend_from_slice(&buffer[..bytes_read]);
740                if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") {
741                    break;
742                }
743            }
744
745            let request = String::from_utf8_lossy(&request_bytes).to_string();
746            let _ = request_tx.send(request);
747
748            let response = format!(
749                "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {content_length}\r\nConnection: close\r\n\r\n{body}",
750                content_length = body.len()
751            );
752            stream
753                .write_all(response.as_bytes())
754                .expect("mock server should write response");
755            stream.flush().expect("mock server should flush response");
756        });
757
758        Ok((format!("http://{address}"), request_rx, server_thread))
759    }
760}