@hraness/wordcell 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  //! Bounded parsing of the fixed DuckDuckGo HTML endpoint. An unrecognized page
2
2
  //! is unavailable evidence, never proof that a query has no results.
3
+ use super::FailureCode;
3
4
  use std::time::Duration;
4
5
 
5
6
  use metadata_search_engine_rs::models::SearchResult;
@@ -10,7 +11,7 @@ use url::Url;
10
11
  const ENDPOINT: &str = "https://html.duckduckgo.com/html/";
11
12
  const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
12
13
 
13
- type Outcome = Result<Vec<SearchResult>, &'static str>;
14
+ type Outcome = Result<Vec<SearchResult>, FailureCode>;
14
15
 
15
16
  pub async fn search(client: &Client, query: &str, limit: usize, timeout: Duration) -> Outcome {
16
17
  search_at(client, ENDPOINT, query, limit, timeout).await
@@ -29,30 +30,34 @@ async fn search_at(
29
30
  .query(&[("q", query)])
30
31
  .send()
31
32
  .await
32
- .map_err(|_| "http")?;
33
+ .map_err(|error| super::transport_code(&error, FailureCode::Http))?;
33
34
  validate_response(
34
35
  response.status(),
35
36
  response.headers(),
36
37
  response.content_length(),
37
38
  )?;
38
39
  let mut body = Vec::new();
39
- while let Some(chunk) = response.chunk().await.map_err(|_| "http-body")? {
40
+ while let Some(chunk) = response
41
+ .chunk()
42
+ .await
43
+ .map_err(|error| super::transport_code(&error, FailureCode::HttpBody))?
44
+ {
40
45
  append_body(&mut body, &chunk)?;
41
46
  }
42
- let html = std::str::from_utf8(&body).map_err(|_| "body-encoding")?;
47
+ let html = std::str::from_utf8(&body).map_err(|_| FailureCode::BodyEncoding)?;
43
48
  parse(html, limit)
44
49
  })
45
50
  .await
46
- .map_err(|_| "timeout")?
51
+ .map_err(|_| FailureCode::Timeout)?
47
52
  }
48
53
 
49
54
  fn validate_response(
50
55
  status: reqwest::StatusCode,
51
56
  headers: &header::HeaderMap,
52
57
  length: Option<u64>,
53
- ) -> Result<(), &'static str> {
58
+ ) -> Result<(), FailureCode> {
54
59
  if !status.is_success() {
55
- return Err("http-status");
60
+ return Err(super::status_code(status.as_u16()));
56
61
  }
57
62
  let content_type = headers
58
63
  .get(header::CONTENT_TYPE)
@@ -65,21 +70,21 @@ fn validate_response(
65
70
  if !content_type.eq_ignore_ascii_case("text/html")
66
71
  && !content_type.eq_ignore_ascii_case("application/xhtml+xml")
67
72
  {
68
- return Err("content-type");
73
+ return Err(FailureCode::ContentType);
69
74
  }
70
75
  // reqwest removes this header after a supported decoding operation.
71
76
  if headers.get(header::CONTENT_ENCODING).is_some() {
72
- return Err("content-encoding");
77
+ return Err(FailureCode::ContentEncoding);
73
78
  }
74
79
  if length.is_some_and(|length| length > MAX_BODY_BYTES as u64) {
75
- return Err("body-limit");
80
+ return Err(FailureCode::BodyLimit);
76
81
  }
77
82
  Ok(())
78
83
  }
79
84
 
80
- fn append_body(body: &mut Vec<u8>, chunk: &[u8]) -> Result<(), &'static str> {
85
+ fn append_body(body: &mut Vec<u8>, chunk: &[u8]) -> Result<(), FailureCode> {
81
86
  if chunk.len() > MAX_BODY_BYTES.saturating_sub(body.len()) {
82
- return Err("body-limit");
87
+ return Err(FailureCode::BodyLimit);
83
88
  }
84
89
  body.extend_from_slice(chunk);
85
90
  Ok(())
@@ -124,10 +129,10 @@ fn destination(href: &str) -> Option<String> {
124
129
 
125
130
  fn parse(html: &str, limit: usize) -> Outcome {
126
131
  if html.len() > MAX_BODY_BYTES {
127
- return Err("body-limit");
132
+ return Err(FailureCode::BodyLimit);
128
133
  }
129
134
  if limit == 0 || limit > 20 {
130
- return Err("result-limit");
135
+ return Err(FailureCode::ResultLimit);
131
136
  }
132
137
  let document = Html::parse_document(html);
133
138
  if document
@@ -137,7 +142,7 @@ fn parse(html: &str, limit: usize) -> Outcome {
137
142
  .next()
138
143
  .is_some()
139
144
  {
140
- return Err("challenge");
145
+ return Err(FailureCode::Challenge);
141
146
  }
142
147
  let result_selector = selector("div.result");
143
148
  let title_selector = selector("a.result__a");
@@ -209,7 +214,7 @@ fn parse(html: &str, limit: usize) -> Outcome {
209
214
  if result_containers == 0 && no_results {
210
215
  Ok(Vec::new())
211
216
  } else {
212
- Err("unrecognized-html")
217
+ Err(FailureCode::UnrecognizedHtml)
213
218
  }
214
219
  }
215
220
 
@@ -251,7 +256,7 @@ mod tests {
251
256
  ] {
252
257
  assert_eq!(
253
258
  parse(&format!("{marker}{}", result("https://example.org")), 5).unwrap_err(),
254
- "challenge"
259
+ FailureCode::Challenge
255
260
  );
256
261
  }
257
262
  }
@@ -274,7 +279,7 @@ mod tests {
274
279
  "<div class='result'><h2>unrecognized</h2></div>",
275
280
  "<div class='no-results'>No results found for query</div><div class='result'></div>",
276
281
  ] {
277
- assert_eq!(parse(html, 5).unwrap_err(), "unrecognized-html");
282
+ assert_eq!(parse(html, 5).unwrap_err(), FailureCode::UnrecognizedHtml);
278
283
  }
279
284
  }
280
285
 
@@ -288,7 +293,10 @@ mod tests {
288
293
  "/l/?uddg=https%3A%2F%2Fa.org&uddg=https%3A%2F%2Fb.org",
289
294
  ] {
290
295
  assert!(destination(href).is_none(), "{href}");
291
- assert_eq!(parse(&result(href), 5).unwrap_err(), "unrecognized-html");
296
+ assert_eq!(
297
+ parse(&result(href), 5).unwrap_err(),
298
+ FailureCode::UnrecognizedHtml
299
+ );
292
300
  }
293
301
  }
294
302
 
@@ -300,15 +308,18 @@ mod tests {
300
308
  .len(),
301
309
  2
302
310
  );
303
- assert_eq!(parse("", 0).unwrap_err(), "result-limit");
304
- assert_eq!(parse("", 21).unwrap_err(), "result-limit");
311
+ assert_eq!(parse("", 0).unwrap_err(), FailureCode::ResultLimit);
312
+ assert_eq!(parse("", 21).unwrap_err(), FailureCode::ResultLimit);
305
313
  let mut bytes = vec![0; MAX_BODY_BYTES - 1];
306
314
  append_body(&mut bytes, &[0]).unwrap();
307
- assert_eq!(append_body(&mut bytes, &[1]).unwrap_err(), "body-limit");
315
+ assert_eq!(
316
+ append_body(&mut bytes, &[1]).unwrap_err(),
317
+ FailureCode::BodyLimit
318
+ );
308
319
  assert_eq!(bytes.len(), MAX_BODY_BYTES);
309
320
  assert_eq!(
310
321
  parse(&"x".repeat(MAX_BODY_BYTES + 1), 1).unwrap_err(),
311
- "body-limit"
322
+ FailureCode::BodyLimit
312
323
  );
313
324
  }
314
325
  #[test]
@@ -326,7 +337,7 @@ mod tests {
326
337
  &headers,
327
338
  None
328
339
  ),
329
- Err("http-status")
340
+ Err(super::super::status_code(status))
330
341
  );
331
342
  }
332
343
  assert_eq!(
@@ -335,7 +346,7 @@ mod tests {
335
346
  &headers,
336
347
  Some(MAX_BODY_BYTES as u64 + 1)
337
348
  ),
338
- Err("body-limit")
349
+ Err(FailureCode::BodyLimit)
339
350
  );
340
351
  headers.insert(
341
352
  header::CONTENT_ENCODING,
@@ -343,7 +354,7 @@ mod tests {
343
354
  );
344
355
  assert_eq!(
345
356
  validate_response(reqwest::StatusCode::OK, &headers, None),
346
- Err("content-encoding")
357
+ Err(FailureCode::ContentEncoding)
347
358
  );
348
359
  headers.remove(header::CONTENT_ENCODING);
349
360
  headers.insert(
@@ -352,7 +363,7 @@ mod tests {
352
363
  );
353
364
  assert_eq!(
354
365
  validate_response(reqwest::StatusCode::OK, &headers, None),
355
- Err("content-type")
366
+ Err(FailureCode::ContentType)
356
367
  );
357
368
  }
358
369
 
@@ -409,6 +420,9 @@ mod tests {
409
420
  Err(error) => panic!("fixture accept: {error}"),
410
421
  }
411
422
  };
423
+ // macOS may inherit the listener nonblocking flag on accept.
424
+ // Use the explicit bounded read/write timeouts for fixture I/O.
425
+ stream.set_nonblocking(false).unwrap();
412
426
  stream
413
427
  .set_read_timeout(Some(Duration::from_secs(2)))
414
428
  .unwrap();
@@ -472,31 +486,31 @@ mod tests {
472
486
  "403 Forbidden",
473
487
  "Content-Type: text/html\r\n",
474
488
  Duration::ZERO,
475
- "http-status",
489
+ FailureCode::HttpForbidden,
476
490
  ),
477
491
  (
478
492
  "302 Found",
479
493
  "Content-Type: text/html\r\nLocation: http://127.0.0.1:1/\r\n",
480
494
  Duration::ZERO,
481
- "http-status",
495
+ FailureCode::HttpRedirect,
482
496
  ),
483
497
  (
484
498
  "200 OK",
485
499
  "Content-Type: application/json\r\n",
486
500
  Duration::ZERO,
487
- "content-type",
501
+ FailureCode::ContentType,
488
502
  ),
489
503
  (
490
504
  "200 OK",
491
505
  "Content-Type: text/html\r\nContent-Encoding: deflate\r\n",
492
506
  Duration::ZERO,
493
- "content-encoding",
507
+ FailureCode::ContentEncoding,
494
508
  ),
495
509
  (
496
510
  "200 OK",
497
511
  "Content-Type: text/html\r\n",
498
512
  Duration::from_millis(150),
499
- "timeout",
513
+ FailureCode::Timeout,
500
514
  ),
501
515
  ] {
502
516
  let (url, server) = fixture(status, headers, b"some body".to_vec(), delay);
@@ -529,6 +543,20 @@ mod tests {
529
543
  );
530
544
  let outcome = search_at(&client(), &url, "fixture", 5, Duration::from_secs(2)).await;
531
545
  server.join().unwrap();
532
- assert_eq!(outcome.unwrap_err(), "body-limit");
546
+ assert_eq!(outcome.unwrap_err(), FailureCode::BodyLimit);
547
+ }
548
+ #[tokio::test]
549
+ async fn reqwest_timeout_is_not_reported_as_generic_body_failure() {
550
+ let (url, server) = fixture(
551
+ "200 OK",
552
+ "Content-Type: text/html\r\n",
553
+ b"delayed body".to_vec(),
554
+ Duration::from_millis(300),
555
+ );
556
+ let client =
557
+ super::super::build_pinned_http_client(&[], Duration::from_millis(100)).unwrap();
558
+ let outcome = search_at(&client, &url, "fixture", 3, Duration::from_secs(2)).await;
559
+ server.join().unwrap();
560
+ assert_eq!(outcome.unwrap_err(), FailureCode::Timeout);
533
561
  }
534
562
  }
@@ -1,3 +1,5 @@
1
+ mod bing;
2
+ mod openalex;
1
3
  mod duckduckgo;
2
4
 
3
5
  use std::alloc::{GlobalAlloc, Layout, System};
@@ -18,7 +20,66 @@ use reqwest::{
18
20
  header::{self, HeaderMap, HeaderValue},
19
21
  redirect::Policy,
20
22
  };
21
- use serde::Deserialize;
23
+ use serde::{Deserialize, Serialize};
24
+
25
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
26
+ #[serde(rename_all = "kebab-case")]
27
+ enum FailureCode {
28
+ Timeout,
29
+ Http,
30
+ HttpBody,
31
+ HttpStatus,
32
+ HttpForbidden,
33
+ HttpRateLimited,
34
+ HttpRedirect,
35
+ Parse,
36
+ UnverifiedEmpty,
37
+ Challenge,
38
+ ContentType,
39
+ ContentEncoding,
40
+ BodyLimit,
41
+ BodyEncoding,
42
+ ResultLimit,
43
+ UnrecognizedHtml,
44
+ QueryMismatch,
45
+ }
46
+
47
+ /// Only bounded categorical diagnostics cross the protocol boundary, never error text.
48
+ #[derive(Serialize)]
49
+ struct EngineFailure {
50
+ engine: String,
51
+ code: FailureCode,
52
+ }
53
+
54
+ #[derive(Serialize)]
55
+ struct EngineUsage { engine: String, reported_cost_usd: f64, billing_status: &'static str }
56
+
57
+ #[derive(Serialize)]
58
+ struct DiagnosticResponse {
59
+ #[serde(flatten)]
60
+ response: SearchResponse,
61
+ #[serde(skip_serializing_if = "Option::is_none")]
62
+ engine_failures: Option<Vec<EngineFailure>>,
63
+ #[serde(skip_serializing_if = "Option::is_none")]
64
+ engine_usage: Option<Vec<EngineUsage>>,
65
+ }
66
+
67
+ fn transport_code(error: &reqwest::Error, otherwise: FailureCode) -> FailureCode {
68
+ if error.is_timeout() {
69
+ FailureCode::Timeout
70
+ } else {
71
+ otherwise
72
+ }
73
+ }
74
+
75
+ fn status_code(status: u16) -> FailureCode {
76
+ match status {
77
+ 403 => FailureCode::HttpForbidden,
78
+ 429 => FailureCode::HttpRateLimited,
79
+ 300..=399 => FailureCode::HttpRedirect,
80
+ _ => FailureCode::HttpStatus,
81
+ }
82
+ }
22
83
 
23
84
  const MAX_INPUT_BYTES: u64 = 16 * 1024;
24
85
  const MAX_QUERY_BYTES: usize = 4 * 1024;
@@ -137,12 +198,27 @@ struct EngineHost {
137
198
  #[serde(deny_unknown_fields)]
138
199
  struct SearchRequest {
139
200
  schema_version: u8,
201
+ #[serde(default)]
202
+ diagnostics: bool,
203
+ #[serde(default)]
204
+ bing_rss: bool,
205
+ #[serde(default)]
206
+ openalex_discovery: bool,
140
207
  query: String,
141
208
  max_results: usize,
142
209
  timeout_ms: u64,
143
210
  engine_hosts: Vec<EngineHost>,
144
211
  }
145
212
 
213
+ fn expected_hosts(bing_rss: bool, openalex_discovery: bool) -> Vec<&'static str> {
214
+ let mut hosts = ENGINE_HOSTS.to_vec();
215
+ if bing_rss {
216
+ hosts.push("www.bing.com");
217
+ }
218
+ if openalex_discovery { hosts.push("api.openalex.org"); }
219
+ hosts
220
+ }
221
+
146
222
  fn read_request() -> Result<SearchRequest, &'static str> {
147
223
  let mut bytes = Vec::new();
148
224
  io::stdin()
@@ -165,10 +241,16 @@ fn read_request() -> Result<SearchRequest, &'static str> {
165
241
  if !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&request.timeout_ms) {
166
242
  return Err("invalid-timeout");
167
243
  }
168
- if request.engine_hosts.len() != ENGINE_HOSTS.len() {
244
+ validate_engine_hosts(&request)?;
245
+ Ok(request)
246
+ }
247
+
248
+ fn validate_engine_hosts(request: &SearchRequest) -> Result<(), &'static str> {
249
+ let expected_hosts = expected_hosts(request.bing_rss, request.openalex_discovery);
250
+ if request.engine_hosts.len() != expected_hosts.len() {
169
251
  return Err("invalid-engine-hosts");
170
252
  }
171
- for (host, expected) in request.engine_hosts.iter().zip(ENGINE_HOSTS) {
253
+ for (host, expected) in request.engine_hosts.iter().zip(expected_hosts) {
172
254
  if host.hostname != expected
173
255
  || host.addresses.is_empty()
174
256
  || host.addresses.len() > MAX_ENGINE_ADDRESSES
@@ -176,7 +258,7 @@ fn read_request() -> Result<SearchRequest, &'static str> {
176
258
  return Err("invalid-engine-hosts");
177
259
  }
178
260
  }
179
- Ok(request)
261
+ Ok(())
180
262
  }
181
263
 
182
264
  fn fail(code: &'static str) -> ! {
@@ -332,7 +414,8 @@ async fn run() {
332
414
  Ok(request) => request,
333
415
  Err(code) => fail(code),
334
416
  };
335
- let timeout = Duration::from_millis(request.timeout_ms / ENGINE_HOSTS.len() as u64);
417
+ let timeout =
418
+ Duration::from_millis(request.timeout_ms / expected_hosts(request.bing_rss, request.openalex_discovery).len() as u64);
336
419
  let client = Arc::new(
337
420
  build_pinned_http_client(&request.engine_hosts, timeout).unwrap_or_else(|code| fail(code)),
338
421
  );
@@ -348,11 +431,41 @@ async fn run() {
348
431
  engines_queried.insert(0, "duckduckgo".to_string());
349
432
  let mut successes = Vec::new();
350
433
  let mut engines_failed = Vec::new();
434
+ let mut engine_failures = Vec::new();
435
+ let mut engine_usage = Vec::new();
436
+ // Optional independent backend runs first within the existing total time budget.
437
+ if request.openalex_discovery {
438
+ engines_queried.insert(0, "openalex".to_string());
439
+ match openalex::search(&client, request.query.trim(), request.max_results, timeout).await {
440
+ Ok(discovery) => {
441
+ if let Some(cost)=discovery.reported_cost_usd { engine_usage.push(EngineUsage {engine:"openalex".into(),reported_cost_usd:cost,billing_status:"not-established"}); }
442
+ successes.push(("openalex".to_string(), discovery.results));
443
+ },
444
+ Err(code) => { engines_failed.push("openalex".to_string()); engine_failures.push(EngineFailure {engine:"openalex".to_string(),code}); }
445
+ }
446
+ }
447
+ if request.bing_rss {
448
+ engines_queried.insert(0, "bing".to_string());
449
+ match bing::search(&client, request.query.trim(), request.max_results, timeout).await {
450
+ Ok(results) => successes.push(("bing".to_string(), results)),
451
+ Err(code) => {
452
+ engines_failed.push("bing".to_string());
453
+ engine_failures.push(EngineFailure {
454
+ engine: "bing".to_string(),
455
+ code,
456
+ });
457
+ }
458
+ }
459
+ }
351
460
  match duckduckgo::search(&client, request.query.trim(), request.max_results, timeout).await {
352
461
  Ok(results) => successes.push(("duckduckgo".to_string(), results)),
353
462
  Err(code) => {
354
- eprintln!("metadata search engine duckduckgo: {code}");
463
+ eprintln!("metadata search engine duckduckgo: {code:?}");
355
464
  engines_failed.push("duckduckgo".to_string());
465
+ engine_failures.push(EngineFailure {
466
+ engine: "duckduckgo".to_string(),
467
+ code,
468
+ });
356
469
  }
357
470
  }
358
471
  // Run one upstream engine at a time so its unbounded internal `text()`
@@ -367,17 +480,25 @@ async fn run() {
367
480
  Ok(results) if !results.is_empty() => successes.push((name, results)),
368
481
  Ok(_) => {
369
482
  eprintln!("metadata search engine {name}: unverified-empty");
483
+ engine_failures.push(EngineFailure {
484
+ engine: name.clone(),
485
+ code: FailureCode::UnverifiedEmpty,
486
+ });
370
487
  engines_failed.push(name);
371
488
  }
372
489
  Err(error) => {
373
490
  use metadata_search_engine_rs::error::EngineError;
374
491
  let code = match error {
375
- EngineError::Timeout { .. } => "timeout",
376
- EngineError::Http { .. } => "http",
377
- EngineError::BadStatus { .. } => "http-status",
378
- EngineError::ParseFailed { .. } => "parse",
492
+ EngineError::Timeout { .. } => FailureCode::Timeout,
493
+ EngineError::Http { source, .. } => transport_code(&source, FailureCode::Http),
494
+ EngineError::BadStatus { status, .. } => status_code(status),
495
+ EngineError::ParseFailed { .. } => FailureCode::Parse,
379
496
  };
380
- eprintln!("metadata search engine {name}: {code}");
497
+ eprintln!("metadata search engine {name}: {code:?}");
498
+ engine_failures.push(EngineFailure {
499
+ engine: name.clone(),
500
+ code,
501
+ });
381
502
  engines_failed.push(name);
382
503
  }
383
504
  }
@@ -396,7 +517,112 @@ async fn run() {
396
517
  engines_queried,
397
518
  engines_failed,
398
519
  };
399
- if serde_json::to_writer(io::stdout().lock(), &response).is_err() {
520
+ if serde_json::to_writer(
521
+ io::stdout().lock(),
522
+ &DiagnosticResponse {
523
+ response,
524
+ engine_failures: request.diagnostics.then_some(engine_failures),
525
+ engine_usage: (request.diagnostics && !engine_usage.is_empty()).then_some(engine_usage),
526
+ },
527
+ )
528
+ .is_err()
529
+ {
400
530
  fail("stdout-write");
401
531
  }
402
532
  }
533
+
534
+ #[cfg(test)]
535
+ mod diagnostic_tests {
536
+ use super::*;
537
+ #[test]
538
+ fn emits_categorical_diagnostics_inside_search_protocol() {
539
+ let response = DiagnosticResponse {
540
+ response: SearchResponse {
541
+ query: "query".into(),
542
+ results: vec![],
543
+ engines_queried: vec!["brave".into()],
544
+ engines_failed: vec!["brave".into()],
545
+ },
546
+ engine_usage: None,
547
+ engine_failures: Some(vec![EngineFailure {
548
+ engine: "brave".into(),
549
+ code: FailureCode::HttpForbidden,
550
+ }]),
551
+ };
552
+ let value = serde_json::to_value(response).unwrap();
553
+ assert_eq!(
554
+ value["engine_failures"],
555
+ serde_json::json!([{ "engine": "brave", "code": "http-forbidden" }])
556
+ );
557
+ assert_eq!(value.as_object().unwrap().len(), 5);
558
+ assert_eq!(status_code(429), FailureCode::HttpRateLimited);
559
+ assert_eq!(status_code(302), FailureCode::HttpRedirect);
560
+ assert_eq!(status_code(503), FailureCode::HttpStatus);
561
+ }
562
+ #[test]
563
+ fn diagnostics_are_an_explicit_boolean_opt_in() {
564
+ let legacy = serde_json::json!({"schema_version":1,"query":"query","max_results":3,"timeout_ms":15000,"engine_hosts":[]});
565
+ let old: SearchRequest = serde_json::from_value(legacy.clone()).unwrap();
566
+ assert!(!old.diagnostics);
567
+ for requested in [false, true] {
568
+ let mut input = legacy.clone();
569
+ input["diagnostics"] = serde_json::json!(requested);
570
+ let parsed: SearchRequest = serde_json::from_value(input).unwrap();
571
+ let output = DiagnosticResponse {
572
+ response: SearchResponse {
573
+ query: "query".into(),
574
+ results: vec![],
575
+ engines_queried: vec![],
576
+ engines_failed: vec![],
577
+ },
578
+ engine_failures: parsed.diagnostics.then_some(vec![]),
579
+ engine_usage: None,
580
+ };
581
+ let encoded = serde_json::to_value(output).unwrap();
582
+ assert_eq!(
583
+ encoded.as_object().unwrap().len(),
584
+ if requested { 5 } else { 4 }
585
+ );
586
+ assert_eq!(encoded.get("engine_failures").is_some(), requested);
587
+ }
588
+ for invalid in [
589
+ serde_json::json!(null),
590
+ serde_json::json!(1),
591
+ serde_json::json!("true"),
592
+ ] {
593
+ let mut input = legacy.clone();
594
+ input["diagnostics"] = invalid;
595
+ assert!(serde_json::from_value::<SearchRequest>(input).is_err());
596
+ }
597
+ }
598
+ #[test]
599
+ fn optional_bing_preserves_exact_legacy_host_contract() {
600
+ let mut value = serde_json::json!({"schema_version":1,"query":"query","max_results":3,"timeout_ms":15000,
601
+ "engine_hosts": expected_hosts(false, false).into_iter().map(|hostname| serde_json::json!({"hostname":hostname,"addresses":[{"address":"93.184.216.34","family":4}]})).collect::<Vec<_>>() });
602
+ let legacy: SearchRequest = serde_json::from_value(value.clone()).unwrap();
603
+ assert!(!legacy.bing_rss);
604
+ assert!(validate_engine_hosts(&legacy).is_ok());
605
+ value["bing_rss"] = serde_json::json!(true);
606
+ assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_err());
607
+ value["engine_hosts"].as_array_mut().unwrap().push(serde_json::json!({"hostname":"www.bing.com","addresses":[{"address":"93.184.216.34","family":4}]}));
608
+ assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_ok());
609
+ value["bing_rss"] = serde_json::json!(false);
610
+ assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_err());
611
+ value["bing_rss"] = serde_json::json!("true");
612
+ assert!(serde_json::from_value::<SearchRequest>(value).is_err());
613
+ }
614
+ #[test]
615
+ fn optional_openalex_preserves_legacy_hosts_and_boolean_contract() {
616
+ let mut value=serde_json::json!({"schema_version":1,"query":"query","max_results":3,"timeout_ms":15000,
617
+ "engine_hosts":expected_hosts(false,false).into_iter().map(|hostname|serde_json::json!({"hostname":hostname,"addresses":[{"address":"93.184.216.34","family":4}]})).collect::<Vec<_>>()});
618
+ let legacy:SearchRequest=serde_json::from_value(value.clone()).unwrap();assert!(!legacy.openalex_discovery);
619
+ value["openalex_discovery"]=serde_json::json!(true);
620
+ assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_err());
621
+ value["engine_hosts"].as_array_mut().unwrap().push(serde_json::json!({"hostname":"api.openalex.org","addresses":[{"address":"93.184.216.34","family":4}]}));
622
+ assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_ok());
623
+ value["openalex_discovery"]=serde_json::json!(false);
624
+ assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_err());
625
+ value["openalex_discovery"]=serde_json::json!("true");assert!(serde_json::from_value::<SearchRequest>(value).is_err());
626
+ }
627
+
628
+ }