Skip to main content

sdsforge_core/
enrichment.rs

1/// PubChem CAS lookup and SDS composition enrichment.
2///
3/// Uses the PubChem PUG REST API to verify CAS numbers and retrieve
4/// compound metadata. Network calls are optional — call only when the
5/// `--enrich` flag is passed.
6use 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
15/// PubChem asks clients not to exceed 5 requests/second. Applied before
16/// every outbound request in the detailed-resolution path (which needs two
17/// requests per CAS), so the aggregate rate stays under the limit
18/// regardless of how tightly a caller loops over CAS numbers.
19const MIN_REQUEST_INTERVAL: Duration = Duration::from_millis(210);
20
21/// Retries HTTP 429/503 only, with bounded exponential backoff. All other
22/// non-success statuses (including 400, which is not retryable) fall
23/// through immediately.
24const MAX_RETRIES: u32 = 3;
25const BASE_BACKOFF: Duration = Duration::from_millis(250);
26const MAX_RETRY_AFTER: Duration = Duration::from_secs(30);
27
28/// Cap on the PubChem fault detail included in an error message — never
29/// embed an arbitrary (potentially large, potentially HTML) response body
30/// in full.
31const 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
69/// Look up a CAS number in PubChem and return compound metadata.
70///
71/// Returns `Ok(None)` when the CAS is not found in PubChem.
72/// Returns `Err` only for network or parse errors.
73///
74/// Unchanged since before the detailed-resolution rewrite: requests only
75/// `IUPACName,MolecularFormula,CID` — none of the obsolete SMILES property
76/// names `lookup_cas_detailed` used to request — so it never hit the
77/// PubChem 400 this fix addresses, and existing callers keep their exact
78/// current behavior.
79pub 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    // Retry once after 1 000 ms on HTTP 429 (rate limit).
93    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
130/// Check every CAS number in Section 3 (Composition) against PubChem.
131///
132/// Returns a list of warnings for CAS numbers not found or whose PubChem
133/// IUPAC name differs substantially from the SDS substance name.
134pub 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        // Prefer IUPAC name, fall back to CAS inventory name or generic name.
144        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            // Rate-limit: 250 ms between PubChem requests to avoid HTTP 429.
185            tokio::time::sleep(std::time::Duration::from_millis(250)).await;
186        }
187    }
188
189    warnings
190}
191
192/// A single chemical identity candidate PubChem returned for a CAS number.
193/// Distinct from [`CasInfo`] (which assumes exactly one match, per
194/// `lookup_cas`'s existing behavior) — this carries structure fields too,
195/// and [`CasResolution`] makes ">1 candidate" representable instead of
196/// silently discarding all but the first.
197#[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    /// PubChem's `SMILES` property — includes stereochemistry and isotopes
204    /// where PubChem represents them. The resolver's own value, kept
205    /// separate from anything a normalizer later derives from it. Not to be
206    /// confused with a *canonical* SMILES — chematic's locally-computed
207    /// canonical form is a distinct, separately-provenanced value (see
208    /// `generation::provenance::FieldProvenance::canonical_smiles`).
209    pub smiles: Option<String>,
210    /// PubChem's `ConnectivitySMILES` property — connectivity only, no
211    /// stereochemistry/isotope information. Used as a normalization-input
212    /// fallback only when `smiles` is unavailable (see
213    /// `ChematicNormalizer::normalize`), since it represents strictly less
214    /// of the actual structure.
215    pub connectivity_smiles: Option<String>,
216    pub inchi_key: Option<String>,
217}
218
219/// Result of resolving one CAS number against PubChem, without silently
220/// collapsing multiple matches to the first one the way `lookup_cas` does.
221#[derive(Debug, Clone)]
222pub enum CasResolution {
223    Resolved(ChemicalIdentityCandidate),
224    NotFound,
225    /// PubChem's name-match returned more than one distinct CID for this
226    /// CAS. A material identity ambiguity — callers must not pick one
227    /// automatically (by name similarity, CID order, or any other
228    /// heuristic); see `generation`'s `AmbiguousChemicalIdentity` handling.
229    Ambiguous(Vec<ChemicalIdentityCandidate>),
230}
231
232/// Deduplicates a PubChem CID list (`/IdentifierList/CID`) while preserving
233/// the order PubChem returned them in — pure, no network, testable with
234/// synthetic JSON.
235fn 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
248/// Parses a `compound/cid/{cids}/property/...` response into one candidate
249/// per row, keyed by each row's own `CID` field (not by request position —
250/// PubChem does not guarantee the response preserves request order).
251fn 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
285/// Builds the final [`CasResolution`] from a deduplicated exact-match CID
286/// list and the per-CID property lookup. Classification is driven by
287/// `cids.len()` — the exact-synonym CID count from PubChem's own `cids`
288/// endpoint — never by how many property rows happened to come back, so
289/// "0/1/2+ CIDs" maps directly to `NotFound`/`Resolved`/`Ambiguous`. A CID
290/// present in the list but inexplicably missing from the property response
291/// still produces a (mostly-empty) candidate rather than silently
292/// vanishing, so the CID count and candidate count always agree.
293fn 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
322/// Truncates `s` to at most `max_bytes` bytes on a UTF-8 char boundary,
323/// appending `...` if truncated.
324fn 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
335/// Formats a bounded, sanitized diagnostic from a non-success PubChem
336/// response body. Prefers the structured `Fault.Message`/`Fault.Details`
337/// fields PUG REST returns for most error responses; falls back to a
338/// truncated raw body for anything else (e.g. an HTML error page) rather
339/// than including it in full. Pure — takes the already-fetched body text,
340/// not a live `Response`, so it's testable without HTTP.
341fn 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
378/// Waits out `Retry-After` (seconds, capped) if PubChem sent one; otherwise
379/// `None`, and the caller falls back to its own backoff.
380fn 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
388/// Issues a GET, retrying only HTTP 429/503 with bounded backoff (honoring
389/// `Retry-After` when PubChem sends one). Every attempt — including the
390/// first — is preceded by [`MIN_REQUEST_INTERVAL`], so this bounds the
391/// aggregate PubChem request rate regardless of caller loop behavior.
392/// Returns whatever the final response was (success, a non-retryable
393/// error, or the last retryable error after the retry cap) — response
394/// interpretation (404 vs. other errors vs. success) is the caller's job.
395async 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
476/// Like [`lookup_cas`], but never silently discards additional PubChem
477/// candidates — returns [`CasResolution::Ambiguous`] instead of picking the
478/// first. Two-step exact resolution, not a single combined
479/// `compound/name/{cas}/property/...` request: step 1 resolves the CAS
480/// synonym to its exact-match CID list (`compound/name/{cas}/cids/JSON?
481/// name_type=complete`); step 2 retrieves properties for that CID list
482/// (`compound/cid/{cids}/property/...`). A name lookup can map to more than
483/// one CID, and PubChem's combined name+property endpoint does not
484/// guarantee it surfaces all of them the same way the dedicated `cids`
485/// endpoint does — this is also what fixed the HTTP 400 the old single-step
486/// request produced by requesting the obsolete `CanonicalSMILES`/
487/// `IsomericSMILES` property names (current PUG REST uses `SMILES`/
488/// `ConnectivitySMILES`; `CID` is never requested as a property, since
489/// every property row already carries its own `CID`).
490///
491/// `lookup_cas` itself is unchanged — it requests none of the property
492/// names this fix touches, so it never needed a two-step rewrite.
493pub 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
518/// Word-level Jaccard similarity check (threshold ≥ 0.5, case-insensitive).
519///
520/// The old substring approach produced false positives for short common words
521/// (e.g. "acid" matching "acetic acid") and was O(n²) on long names.
522/// Jaccard on word tokens is both more precise and still O(n).
523fn 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    // -- pure parsing: parse_cid_list -------------------------------------
569
570    #[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    // -- pure parsing: parse_candidates_by_cid / candidate_from_properties -
595
596    #[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    // -- pure: build_resolution --------------------------------------------
619
620    #[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    // -- pure: format_pubchem_fault / truncate_bounded ----------------------
670
671    #[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        // Each "あ" is 3 bytes in UTF-8; a naive byte-index slice at an odd
707        // boundary would panic.
708        let s = "あ".repeat(1000);
709        let truncated = truncate_bounded(&s, 10);
710        assert!(truncated.len() <= 13); // 10 + "..."
711    }
712
713    // -- HTTP-mocked: request shape, ambiguity, retry/rate-limit -----------
714
715    #[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        // wiremock's .expect(1) on each Mock asserts on drop that exactly
743        // one matching request (with this exact path/property list) was
744        // received -- proving CanonicalSMILES/IsomericSMILES/CID-as-property
745        // were never requested, since a mismatched request would 404
746        // against these mocks instead of matching.
747    }
748
749    #[tokio::test]
750    async fn zero_cids_is_not_found_without_a_second_request() {
751        let server = MockServer::start().await;
752        // A well-formed, valid-check-digit CAS (aspirin) that simply isn't
753        // found -- distinct from a malformed CAS, which is rejected before
754        // any request is ever made.
755        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        // No mock registered for /compound/cid/... -- if the code ever
764        // called it, wiremock would return its default 404 and the
765        // resolution would incorrectly become NotFound-via-error instead
766        // of NotFound-via-empty-CID-list, but the real assertion is the
767        // request count captured below.
768
769        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        // Initial attempt + MAX_RETRIES retries, never more.
907        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        // "bad-cas" isn't a valid CAS format, so validate_cas_format rejects
918        // it before lookup_cas_detailed_at ever issues a request -- no mock
919        // is registered, so a stray request would fail the test outright.
920        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        // "50-78-2" is a well-formed CAS (aspirin's) that the mock server
952        // answers with a real HTTP 400 -- this exercises the network-error
953        // path, not the format guard, and proves it doesn't corrupt or
954        // block the independent lookup that follows.
955        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}