1use std::collections::{HashMap, HashSet};
7use std::time::Duration;
8
9use crate::converter::validator::validate_cas_format;
10use crate::error::SdsError;
11use crate::schema::SdsRoot;
12
13const PUBCHEM_BASE_URL: &str = "https://pubchem.ncbi.nlm.nih.gov/rest/pug";
14
15const MIN_REQUEST_INTERVAL: Duration = Duration::from_millis(210);
20
21const MAX_RETRIES: u32 = 3;
25const BASE_BACKOFF: Duration = Duration::from_millis(250);
26const MAX_RETRY_AFTER: Duration = Duration::from_secs(30);
27
28const MAX_FAULT_MESSAGE_LEN: usize = 2048;
32
33#[derive(Debug, Clone)]
34pub struct CasInfo {
35 pub cas: String,
36 pub iupac_name: Option<String>,
37 pub molecular_formula: Option<String>,
38 pub pubchem_cid: Option<u64>,
39}
40
41#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
42pub enum CasWarning {
43 NotFound {
44 cas: String,
45 },
46 NameMismatch {
47 cas: String,
48 pubchem_name: String,
49 sds_name: String,
50 },
51}
52
53impl std::fmt::Display for CasWarning {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 CasWarning::NotFound { cas } => write!(f, "CAS {cas}: not found in PubChem"),
57 CasWarning::NameMismatch {
58 cas,
59 pubchem_name,
60 sds_name,
61 } => write!(
62 f,
63 "CAS {cas}: PubChem name '{pubchem_name}' differs from SDS name '{sds_name}'"
64 ),
65 }
66 }
67}
68
69pub async fn lookup_cas(cas: &str, client: &reqwest::Client) -> Result<Option<CasInfo>, SdsError> {
80 if !validate_cas_format(cas) {
81 return Err(SdsError::Extract(format!("Invalid CAS number: {cas:?}")));
82 }
83 let url = format!(
84 "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/{cas}/property/IUPACName,MolecularFormula,CID/JSON"
85 );
86 let mut resp = client
87 .get(&url)
88 .send()
89 .await
90 .map_err(|e| SdsError::Extract(format!("PubChem request failed: {e}")))?;
91
92 if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
94 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
95 resp = client
96 .get(&url)
97 .send()
98 .await
99 .map_err(|e| SdsError::Extract(format!("PubChem request failed (retry): {e}")))?;
100 }
101
102 if resp.status() == reqwest::StatusCode::NOT_FOUND {
103 return Ok(None);
104 }
105 if !resp.status().is_success() {
106 return Err(SdsError::Extract(format!(
107 "PubChem returned HTTP {}",
108 resp.status()
109 )));
110 }
111
112 let body: serde_json::Value = resp
113 .json()
114 .await
115 .map_err(|e| SdsError::Extract(format!("PubChem JSON parse failed: {e}")))?;
116
117 let props = body
118 .pointer("/PropertyTable/Properties/0")
119 .cloned()
120 .unwrap_or(serde_json::Value::Null);
121
122 Ok(Some(CasInfo {
123 cas: cas.to_string(),
124 iupac_name: props["IUPACName"].as_str().map(str::to_string),
125 molecular_formula: props["MolecularFormula"].as_str().map(str::to_string),
126 pubchem_cid: props["CID"].as_u64(),
127 }))
128}
129
130pub async fn enrich_composition(sds: &SdsRoot, client: &reqwest::Client) -> Vec<CasWarning> {
135 let mut warnings = Vec::new();
136 let items = sds
137 .composition
138 .as_ref()
139 .and_then(|c| c.composition_and_concentration.as_deref())
140 .unwrap_or(&[]);
141
142 for item in items {
143 let sds_name = item
145 .substance_identifiers
146 .as_ref()
147 .and_then(|ids| ids.substance_names.as_ref())
148 .and_then(|sn| {
149 sn.iupac_name
150 .as_deref()
151 .or(sn.cas_inventory_name.as_deref())
152 .or(sn.generic_name.as_deref())
153 })
154 .unwrap_or("")
155 .to_string();
156
157 let cas_numbers: Vec<String> = item
158 .substance_identifiers
159 .as_ref()
160 .and_then(|ids| ids.substance_identity.as_ref())
161 .and_then(|si| si.ca_sno.as_ref())
162 .and_then(|c| c.full_text.as_deref())
163 .unwrap_or(&[])
164 .to_vec();
165
166 for cas in &cas_numbers {
167 match lookup_cas(cas, client).await {
168 Ok(None) => warnings.push(CasWarning::NotFound { cas: cas.clone() }),
169 Ok(Some(info)) => {
170 if let Some(pubchem_name) = &info.iupac_name {
171 if !sds_name.is_empty() && !names_similar(pubchem_name, &sds_name) {
172 warnings.push(CasWarning::NameMismatch {
173 cas: cas.clone(),
174 pubchem_name: pubchem_name.clone(),
175 sds_name: sds_name.clone(),
176 });
177 }
178 }
179 }
180 Err(e) => {
181 tracing::warn!("PubChem lookup error for CAS {cas}: {e}");
182 }
183 }
184 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
186 }
187 }
188
189 warnings
190}
191
192#[derive(Debug, Clone)]
198pub struct ChemicalIdentityCandidate {
199 pub cas: String,
200 pub pubchem_cid: Option<u64>,
201 pub iupac_name: Option<String>,
202 pub molecular_formula: Option<String>,
203 pub smiles: Option<String>,
210 pub connectivity_smiles: Option<String>,
216 pub inchi_key: Option<String>,
217}
218
219#[derive(Debug, Clone)]
222pub enum CasResolution {
223 Resolved(ChemicalIdentityCandidate),
224 NotFound,
225 Ambiguous(Vec<ChemicalIdentityCandidate>),
230}
231
232fn parse_cid_list(body: &serde_json::Value) -> Vec<u64> {
236 let mut seen = HashSet::new();
237 body.pointer("/IdentifierList/CID")
238 .and_then(|v| v.as_array())
239 .map(|arr| {
240 arr.iter()
241 .filter_map(|v| v.as_u64())
242 .filter(|cid| seen.insert(*cid))
243 .collect()
244 })
245 .unwrap_or_default()
246}
247
248fn parse_candidates_by_cid(
252 cas: &str,
253 body: &serde_json::Value,
254) -> HashMap<u64, ChemicalIdentityCandidate> {
255 body.pointer("/PropertyTable/Properties")
256 .and_then(|v| v.as_array())
257 .map(|props| {
258 props
259 .iter()
260 .filter_map(|p| {
261 let cid = p["CID"].as_u64()?;
262 Some((cid, candidate_from_properties(cas, cid, p)))
263 })
264 .collect()
265 })
266 .unwrap_or_default()
267}
268
269fn candidate_from_properties(
270 cas: &str,
271 cid: u64,
272 props: &serde_json::Value,
273) -> ChemicalIdentityCandidate {
274 ChemicalIdentityCandidate {
275 cas: cas.to_string(),
276 pubchem_cid: Some(cid),
277 iupac_name: props["IUPACName"].as_str().map(str::to_string),
278 molecular_formula: props["MolecularFormula"].as_str().map(str::to_string),
279 smiles: props["SMILES"].as_str().map(str::to_string),
280 connectivity_smiles: props["ConnectivitySMILES"].as_str().map(str::to_string),
281 inchi_key: props["InChIKey"].as_str().map(str::to_string),
282 }
283}
284
285fn build_resolution(
294 cas: &str,
295 cids: &[u64],
296 mut properties_by_cid: HashMap<u64, ChemicalIdentityCandidate>,
297) -> CasResolution {
298 let candidates: Vec<ChemicalIdentityCandidate> = cids
299 .iter()
300 .map(|cid| {
301 properties_by_cid
302 .remove(cid)
303 .unwrap_or_else(|| ChemicalIdentityCandidate {
304 cas: cas.to_string(),
305 pubchem_cid: Some(*cid),
306 iupac_name: None,
307 molecular_formula: None,
308 smiles: None,
309 connectivity_smiles: None,
310 inchi_key: None,
311 })
312 })
313 .collect();
314
315 match candidates.len() {
316 0 => CasResolution::NotFound,
317 1 => CasResolution::Resolved(candidates.into_iter().next().expect("len checked above")),
318 _ => CasResolution::Ambiguous(candidates),
319 }
320}
321
322fn truncate_bounded(s: &str, max_bytes: usize) -> String {
325 if s.len() <= max_bytes {
326 return s.to_string();
327 }
328 let mut end = max_bytes;
329 while end > 0 && !s.is_char_boundary(end) {
330 end -= 1;
331 }
332 format!("{}...", &s[..end])
333}
334
335fn format_pubchem_fault(status: reqwest::StatusCode, body: &str) -> String {
342 let detail = serde_json::from_str::<serde_json::Value>(body)
343 .ok()
344 .and_then(|v| {
345 let message = v
346 .pointer("/Fault/Message")
347 .and_then(|m| m.as_str())
348 .map(str::to_string);
349 let details = v
350 .pointer("/Fault/Details")
351 .and_then(|d| d.as_array())
352 .map(|arr| {
353 arr.iter()
354 .filter_map(|x| x.as_str())
355 .collect::<Vec<_>>()
356 .join("; ")
357 });
358 match (message, details) {
359 (Some(m), Some(d)) if !d.is_empty() => Some(format!("{m} — {d}")),
360 (Some(m), _) => Some(m),
361 (None, Some(d)) if !d.is_empty() => Some(d),
362 _ => None,
363 }
364 })
365 .unwrap_or_else(|| body.to_string());
366
367 format!(
368 "PubChem returned HTTP {status}: {}",
369 truncate_bounded(&detail, MAX_FAULT_MESSAGE_LEN)
370 )
371}
372
373async fn describe_pubchem_error(status: reqwest::StatusCode, resp: reqwest::Response) -> SdsError {
374 let body = resp.text().await.unwrap_or_default();
375 SdsError::Extract(format_pubchem_fault(status, &body))
376}
377
378fn retry_after_duration(resp: &reqwest::Response) -> Option<Duration> {
381 resp.headers()
382 .get(reqwest::header::RETRY_AFTER)
383 .and_then(|v| v.to_str().ok())
384 .and_then(|s| s.parse::<u64>().ok())
385 .map(|secs| Duration::from_secs(secs).min(MAX_RETRY_AFTER))
386}
387
388async fn get_with_retry(
396 client: &reqwest::Client,
397 url: &str,
398) -> Result<reqwest::Response, SdsError> {
399 let mut attempt = 0;
400 loop {
401 tokio::time::sleep(MIN_REQUEST_INTERVAL).await;
402 let resp = client
403 .get(url)
404 .send()
405 .await
406 .map_err(|e| SdsError::Extract(format!("PubChem request failed: {e}")))?;
407
408 let status = resp.status();
409 let retryable = status == reqwest::StatusCode::TOO_MANY_REQUESTS
410 || status == reqwest::StatusCode::SERVICE_UNAVAILABLE;
411
412 if !retryable || attempt >= MAX_RETRIES {
413 return Ok(resp);
414 }
415
416 let delay = retry_after_duration(&resp).unwrap_or_else(|| BASE_BACKOFF * 2u32.pow(attempt));
417 tokio::time::sleep(delay).await;
418 attempt += 1;
419 }
420}
421
422async fn resolve_cids(
423 cas: &str,
424 client: &reqwest::Client,
425 base_url: &str,
426) -> Result<Vec<u64>, SdsError> {
427 let url = format!("{base_url}/compound/name/{cas}/cids/JSON?name_type=complete");
428 let resp = get_with_retry(client, &url).await?;
429
430 if resp.status() == reqwest::StatusCode::NOT_FOUND {
431 return Ok(Vec::new());
432 }
433 if !resp.status().is_success() {
434 let status = resp.status();
435 return Err(describe_pubchem_error(status, resp).await);
436 }
437
438 let body: serde_json::Value = resp
439 .json()
440 .await
441 .map_err(|e| SdsError::Extract(format!("PubChem JSON parse failed: {e}")))?;
442 Ok(parse_cid_list(&body))
443}
444
445async fn fetch_properties_by_cid(
446 cas: &str,
447 cids: &[u64],
448 client: &reqwest::Client,
449 base_url: &str,
450) -> Result<HashMap<u64, ChemicalIdentityCandidate>, SdsError> {
451 let cid_list = cids
452 .iter()
453 .map(u64::to_string)
454 .collect::<Vec<_>>()
455 .join(",");
456 let url = format!(
457 "{base_url}/compound/cid/{cid_list}/property/IUPACName,MolecularFormula,SMILES,ConnectivitySMILES,InChIKey/JSON"
458 );
459 let resp = get_with_retry(client, &url).await?;
460
461 if resp.status() == reqwest::StatusCode::NOT_FOUND {
462 return Ok(HashMap::new());
463 }
464 if !resp.status().is_success() {
465 let status = resp.status();
466 return Err(describe_pubchem_error(status, resp).await);
467 }
468
469 let body: serde_json::Value = resp
470 .json()
471 .await
472 .map_err(|e| SdsError::Extract(format!("PubChem JSON parse failed: {e}")))?;
473 Ok(parse_candidates_by_cid(cas, &body))
474}
475
476pub async fn lookup_cas_detailed(
494 cas: &str,
495 client: &reqwest::Client,
496) -> Result<CasResolution, SdsError> {
497 lookup_cas_detailed_at(cas, client, PUBCHEM_BASE_URL).await
498}
499
500async fn lookup_cas_detailed_at(
501 cas: &str,
502 client: &reqwest::Client,
503 base_url: &str,
504) -> Result<CasResolution, SdsError> {
505 if !validate_cas_format(cas) {
506 return Err(SdsError::Extract(format!("Invalid CAS number: {cas:?}")));
507 }
508
509 let cids = resolve_cids(cas, client, base_url).await?;
510 if cids.is_empty() {
511 return Ok(CasResolution::NotFound);
512 }
513
514 let properties_by_cid = fetch_properties_by_cid(cas, &cids, client, base_url).await?;
515 Ok(build_resolution(cas, &cids, properties_by_cid))
516}
517
518fn names_similar(a: &str, b: &str) -> bool {
524 let a_lo = a.to_lowercase();
525 let b_lo = b.to_lowercase();
526 let words_a: HashSet<&str> = a_lo.split_whitespace().collect();
527 let words_b: HashSet<&str> = b_lo.split_whitespace().collect();
528 if words_a.is_empty() || words_b.is_empty() {
529 return false;
530 }
531 let intersection = words_a.intersection(&words_b).count();
532 let union = words_a.union(&words_b).count();
533 (intersection as f64 / union as f64) >= 0.5
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use wiremock::matchers::{method, path, query_param};
540 use wiremock::{Mock, MockServer, ResponseTemplate};
541
542 #[test]
543 fn names_similar_identical() {
544 assert!(names_similar("acetic acid", "acetic acid"));
545 }
546
547 #[test]
548 fn names_similar_partial_overlap_above_threshold() {
549 assert!(names_similar("acetic acid solution", "acetic acid"));
550 }
551
552 #[test]
553 fn names_similar_no_overlap() {
554 assert!(!names_similar("sodium chloride", "acetic acid"));
555 }
556
557 #[test]
558 fn names_similar_short_common_word_no_false_positive() {
559 assert!(!names_similar("salt", "sodium chloride"));
560 }
561
562 #[test]
563 fn names_similar_empty_string() {
564 assert!(!names_similar("", "acetic acid"));
565 assert!(!names_similar("acetic acid", ""));
566 }
567
568 #[test]
571 fn parse_cid_list_empty() {
572 let body = serde_json::json!({ "IdentifierList": { "CID": [] } });
573 assert_eq!(parse_cid_list(&body), Vec::<u64>::new());
574 }
575
576 #[test]
577 fn parse_cid_list_missing_key() {
578 let body = serde_json::json!({});
579 assert_eq!(parse_cid_list(&body), Vec::<u64>::new());
580 }
581
582 #[test]
583 fn parse_cid_list_single() {
584 let body = serde_json::json!({ "IdentifierList": { "CID": [702] } });
585 assert_eq!(parse_cid_list(&body), vec![702]);
586 }
587
588 #[test]
589 fn parse_cid_list_deduplicates_preserving_order() {
590 let body = serde_json::json!({ "IdentifierList": { "CID": [5, 3, 5, 1, 3] } });
591 assert_eq!(parse_cid_list(&body), vec![5, 3, 1]);
592 }
593
594 #[test]
597 fn candidate_parsing_maps_smiles() {
598 let body = serde_json::json!({
599 "PropertyTable": { "Properties": [
600 {"CID": 702, "SMILES": "CCO", "IUPACName": "ethanol"}
601 ] }
602 });
603 let by_cid = parse_candidates_by_cid("64-17-5", &body);
604 assert_eq!(by_cid[&702].smiles.as_deref(), Some("CCO"));
605 }
606
607 #[test]
608 fn candidate_parsing_maps_connectivity_smiles() {
609 let body = serde_json::json!({
610 "PropertyTable": { "Properties": [
611 {"CID": 702, "ConnectivitySMILES": "CCO"}
612 ] }
613 });
614 let by_cid = parse_candidates_by_cid("64-17-5", &body);
615 assert_eq!(by_cid[&702].connectivity_smiles.as_deref(), Some("CCO"));
616 }
617
618 #[test]
621 fn build_resolution_zero_cids_is_not_found() {
622 let resolution = build_resolution("0-00-0", &[], HashMap::new());
623 assert!(matches!(resolution, CasResolution::NotFound));
624 }
625
626 #[test]
627 fn build_resolution_one_cid_is_resolved() {
628 let mut props = HashMap::new();
629 props.insert(
630 702,
631 candidate_from_properties(
632 "64-17-5",
633 702,
634 &serde_json::json!({"IUPACName": "ethanol", "SMILES": "CCO"}),
635 ),
636 );
637 let resolution = build_resolution("64-17-5", &[702], props);
638 match resolution {
639 CasResolution::Resolved(c) => {
640 assert_eq!(c.pubchem_cid, Some(702));
641 assert_eq!(c.iupac_name.as_deref(), Some("ethanol"));
642 }
643 other => panic!("expected Resolved, got {other:?}"),
644 }
645 }
646
647 #[test]
648 fn build_resolution_two_cids_is_ambiguous_preserving_all_candidates() {
649 let mut props = HashMap::new();
650 props.insert(
651 1,
652 candidate_from_properties("64-17-5", 1, &serde_json::json!({})),
653 );
654 props.insert(
655 2,
656 candidate_from_properties("64-17-5", 2, &serde_json::json!({})),
657 );
658 let resolution = build_resolution("64-17-5", &[1, 2], props);
659 match resolution {
660 CasResolution::Ambiguous(candidates) => {
661 assert_eq!(candidates.len(), 2);
662 let cids: Vec<Option<u64>> = candidates.iter().map(|c| c.pubchem_cid).collect();
663 assert_eq!(cids, vec![Some(1), Some(2)]);
664 }
665 other => panic!("expected Ambiguous, got {other:?}"),
666 }
667 }
668
669 #[test]
672 fn fault_message_prefers_structured_fault_fields() {
673 let body = serde_json::json!({
674 "Fault": {"Code": "PUGREST.BadRequest", "Message": "Invalid property"}
675 })
676 .to_string();
677 let msg = format_pubchem_fault(reqwest::StatusCode::BAD_REQUEST, &body);
678 assert!(msg.contains("Invalid property"));
679 assert!(msg.contains("400"));
680 }
681
682 #[test]
683 fn fault_message_includes_details_when_present() {
684 let body = serde_json::json!({
685 "Fault": {
686 "Code": "PUGREST.NotFound",
687 "Message": "No CID found",
688 "Details": ["No CID found that matches the given name"]
689 }
690 })
691 .to_string();
692 let msg = format_pubchem_fault(reqwest::StatusCode::NOT_FOUND, &body);
693 assert!(msg.contains("No CID found"));
694 assert!(msg.contains("matches the given name"));
695 }
696
697 #[test]
698 fn fault_message_is_bounded() {
699 let huge = "<html>".to_string() + &"x".repeat(10_000) + "</html>";
700 let msg = format_pubchem_fault(reqwest::StatusCode::BAD_REQUEST, &huge);
701 assert!(msg.len() < MAX_FAULT_MESSAGE_LEN + 100);
702 }
703
704 #[test]
705 fn truncate_bounded_does_not_panic_on_multibyte_boundary() {
706 let s = "あ".repeat(1000);
709 let truncated = truncate_bounded(&s, 10);
710 assert!(truncated.len() <= 13); }
712
713 #[tokio::test]
716 async fn request_uses_smiles_and_connectivity_smiles_not_obsolete_names() {
717 let server = MockServer::start().await;
718 Mock::given(method("GET"))
719 .and(path("/compound/name/64-17-5/cids/JSON"))
720 .and(query_param("name_type", "complete"))
721 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
722 "IdentifierList": { "CID": [702] }
723 })))
724 .expect(1)
725 .mount(&server)
726 .await;
727 Mock::given(method("GET"))
728 .and(path("/compound/cid/702/property/IUPACName,MolecularFormula,SMILES,ConnectivitySMILES,InChIKey/JSON"))
729 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
730 "PropertyTable": { "Properties": [
731 {"CID": 702, "IUPACName": "ethanol", "MolecularFormula": "C2H6O",
732 "SMILES": "CCO", "ConnectivitySMILES": "CCO", "InChIKey": "LFQSCWFLJHTTHZ-UHFFFAOYSA-N"}
733 ] }
734 })))
735 .expect(1)
736 .mount(&server)
737 .await;
738
739 let client = reqwest::Client::new();
740 let result = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
741 assert!(matches!(result, Ok(CasResolution::Resolved(_))));
742 }
748
749 #[tokio::test]
750 async fn zero_cids_is_not_found_without_a_second_request() {
751 let server = MockServer::start().await;
752 Mock::given(method("GET"))
756 .and(path("/compound/name/50-78-2/cids/JSON"))
757 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
758 "IdentifierList": { "CID": [] }
759 })))
760 .expect(1)
761 .mount(&server)
762 .await;
763 let client = reqwest::Client::new();
770 let result = lookup_cas_detailed_at("50-78-2", &client, &server.uri()).await;
771 assert!(matches!(result, Ok(CasResolution::NotFound)));
772 assert_eq!(server.received_requests().await.unwrap().len(), 1);
773 }
774
775 #[tokio::test]
776 async fn duplicate_cids_are_deduplicated_before_the_property_request() {
777 let server = MockServer::start().await;
778 Mock::given(method("GET"))
779 .and(path("/compound/name/64-17-5/cids/JSON"))
780 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
781 "IdentifierList": { "CID": [702, 702] }
782 })))
783 .mount(&server)
784 .await;
785 Mock::given(method("GET"))
786 .and(path("/compound/cid/702/property/IUPACName,MolecularFormula,SMILES,ConnectivitySMILES,InChIKey/JSON"))
787 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
788 "PropertyTable": { "Properties": [{"CID": 702, "IUPACName": "ethanol"}] }
789 })))
790 .expect(1)
791 .mount(&server)
792 .await;
793
794 let client = reqwest::Client::new();
795 let result = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
796 assert!(matches!(result, Ok(CasResolution::Resolved(_))));
797 }
798
799 #[tokio::test]
800 async fn multiple_distinct_cids_yield_ambiguous() {
801 let server = MockServer::start().await;
802 Mock::given(method("GET"))
803 .and(path("/compound/name/64-17-5/cids/JSON"))
804 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
805 "IdentifierList": { "CID": [1, 2] }
806 })))
807 .mount(&server)
808 .await;
809 Mock::given(method("GET"))
810 .and(path("/compound/cid/1,2/property/IUPACName,MolecularFormula,SMILES,ConnectivitySMILES,InChIKey/JSON"))
811 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
812 "PropertyTable": { "Properties": [
813 {"CID": 1, "IUPACName": "candidate one"},
814 {"CID": 2, "IUPACName": "candidate two"}
815 ] }
816 })))
817 .mount(&server)
818 .await;
819
820 let client = reqwest::Client::new();
821 let result = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
822 match result {
823 Ok(CasResolution::Ambiguous(candidates)) => assert_eq!(candidates.len(), 2),
824 other => panic!("expected Ambiguous, got {other:?}"),
825 }
826 }
827
828 #[tokio::test]
829 async fn http_400_includes_bounded_fault_detail_and_is_not_retried() {
830 let server = MockServer::start().await;
831 Mock::given(method("GET"))
832 .and(path("/compound/name/64-17-5/cids/JSON"))
833 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
834 "Fault": {"Code": "PUGREST.BadRequest", "Message": "Invalid property"}
835 })))
836 .expect(1)
837 .mount(&server)
838 .await;
839
840 let client = reqwest::Client::new();
841 let result = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
842 let err = result.unwrap_err().to_string();
843 assert!(err.contains("Invalid property"));
844 assert!(err.contains("400"));
845 assert_eq!(server.received_requests().await.unwrap().len(), 1);
846 }
847
848 #[tokio::test]
849 async fn http_429_is_retried_with_backoff() {
850 let server = MockServer::start().await;
851 Mock::given(method("GET"))
852 .and(path("/compound/name/64-17-5/cids/JSON"))
853 .respond_with(ResponseTemplate::new(429))
854 .up_to_n_times(1)
855 .mount(&server)
856 .await;
857 Mock::given(method("GET"))
858 .and(path("/compound/name/64-17-5/cids/JSON"))
859 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
860 "IdentifierList": { "CID": [] }
861 })))
862 .mount(&server)
863 .await;
864
865 let client = reqwest::Client::new();
866 let result = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
867 assert!(matches!(result, Ok(CasResolution::NotFound)));
868 assert_eq!(server.received_requests().await.unwrap().len(), 2);
869 }
870
871 #[tokio::test]
872 async fn http_503_is_retried_with_backoff() {
873 let server = MockServer::start().await;
874 Mock::given(method("GET"))
875 .and(path("/compound/name/64-17-5/cids/JSON"))
876 .respond_with(ResponseTemplate::new(503))
877 .up_to_n_times(1)
878 .mount(&server)
879 .await;
880 Mock::given(method("GET"))
881 .and(path("/compound/name/64-17-5/cids/JSON"))
882 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
883 "IdentifierList": { "CID": [] }
884 })))
885 .mount(&server)
886 .await;
887
888 let client = reqwest::Client::new();
889 let result = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
890 assert!(matches!(result, Ok(CasResolution::NotFound)));
891 assert_eq!(server.received_requests().await.unwrap().len(), 2);
892 }
893
894 #[tokio::test]
895 async fn retries_are_capped() {
896 let server = MockServer::start().await;
897 Mock::given(method("GET"))
898 .and(path("/compound/name/64-17-5/cids/JSON"))
899 .respond_with(ResponseTemplate::new(429))
900 .mount(&server)
901 .await;
902
903 let client = reqwest::Client::new();
904 let result = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
905 assert!(result.is_err());
906 assert_eq!(
908 server.received_requests().await.unwrap().len() as u32,
909 MAX_RETRIES + 1
910 );
911 }
912
913 #[tokio::test]
914 async fn malformed_cas_is_rejected_by_the_format_guard_before_any_request() {
915 let server = MockServer::start().await;
916 let client = reqwest::Client::new();
917 let result = lookup_cas_detailed_at("bad-cas", &client, &server.uri()).await;
921 assert!(result.is_err());
922 assert!(server.received_requests().await.unwrap().is_empty());
923 }
924
925 #[tokio::test]
926 async fn a_pubchem_error_for_one_cas_does_not_prevent_a_later_component_from_resolving() {
927 let server = MockServer::start().await;
928 Mock::given(method("GET"))
929 .and(path("/compound/name/50-78-2/cids/JSON"))
930 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
931 "Fault": {"Code": "PUGREST.BadRequest", "Message": "boom"}
932 })))
933 .mount(&server)
934 .await;
935 Mock::given(method("GET"))
936 .and(path("/compound/name/64-17-5/cids/JSON"))
937 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
938 "IdentifierList": { "CID": [702] }
939 })))
940 .mount(&server)
941 .await;
942 Mock::given(method("GET"))
943 .and(path("/compound/cid/702/property/IUPACName,MolecularFormula,SMILES,ConnectivitySMILES,InChIKey/JSON"))
944 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
945 "PropertyTable": { "Properties": [{"CID": 702, "IUPACName": "ethanol"}] }
946 })))
947 .mount(&server)
948 .await;
949
950 let client = reqwest::Client::new();
951 let first = lookup_cas_detailed_at("50-78-2", &client, &server.uri()).await;
956 assert!(first.is_err());
957 let second = lookup_cas_detailed_at("64-17-5", &client, &server.uri()).await;
958 assert!(matches!(second, Ok(CasResolution::Resolved(_))));
959 }
960}