use serde_json::Value;
use url::Url;
use crate::provenance::{Capability, LogEvent, LogResult, RowInput};
use crate::refs::{ParseError, ParsedEntry};
use crate::source::{FetchContext, FetchError};
use crate::{Doi, Ref};
pub const NCBI: &str = "ncbi";
pub const NCBI_BASE_ENV: &str = "DOIGET_NCBI_BASE";
const NCBI_DEFAULT: &str = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PubmedId {
Pmid(Digits),
Pmcid(Digits),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Digits(String);
impl Digits {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for Digits {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl PubmedId {
#[must_use]
pub fn parse(input: &str) -> Option<Self> {
let s = input.trim();
let lower = s.to_ascii_lowercase();
if let Some(rest) = lower.strip_prefix("pmid:") {
return digits(rest.trim()).map(Self::Pmid);
}
if let Some(rest) = lower.strip_prefix("pmcid:") {
let rest = rest.trim();
return digits(rest.strip_prefix("pmc").unwrap_or(rest)).map(Self::Pmcid);
}
if let Some(rest) = lower.strip_prefix("pmc") {
return digits(rest).map(Self::Pmcid);
}
let url = Url::parse(s).ok()?;
let segs: Vec<&str> = url.path_segments()?.filter(|p| !p.is_empty()).collect();
match (url.host_str()?, segs.as_slice()) {
("pubmed.ncbi.nlm.nih.gov", [id]) => digits(id).map(Self::Pmid),
("www.ncbi.nlm.nih.gov" | "ncbi.nlm.nih.gov", ["pmc", "articles", id]) => {
let id = id.to_ascii_lowercase();
digits(id.strip_prefix("pmc")?).map(Self::Pmcid)
}
("pmc.ncbi.nlm.nih.gov", ["articles", id]) => {
let id = id.to_ascii_lowercase();
digits(id.strip_prefix("pmc")?).map(Self::Pmcid)
}
_ => None,
}
}
#[must_use]
pub fn from_kind(kind: &str, value: &str) -> Option<Self> {
match kind {
"PMID" => digits(value.trim()).map(Self::Pmid),
"PMCID" => {
let v = value.trim().to_ascii_lowercase();
digits(v.strip_prefix("pmc").unwrap_or(&v)).map(Self::Pmcid)
}
_ => None,
}
}
#[must_use]
pub fn display(&self) -> String {
match self {
Self::Pmid(id) => format!("PMID {id}"),
Self::Pmcid(id) => format!("PMCID PMC{id}"),
}
}
fn db_and_id(&self) -> (&'static str, &str) {
match self {
Self::Pmid(id) => ("pubmed", id.as_str()),
Self::Pmcid(id) => ("pmc", id.as_str()),
}
}
}
fn digits(s: &str) -> Option<Digits> {
(!s.is_empty() && s.len() <= 12 && s.chars().all(|c| c.is_ascii_digit()))
.then(|| Digits(s.to_string()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Lookup {
Doi(Doi),
NoDoi,
NoRecord,
}
impl Lookup {
#[must_use]
pub fn reason(&self, id: &PubmedId) -> Option<String> {
match self {
Self::Doi(_) => None,
Self::NoDoi => Some(format!(
"PubMed's record for {} lists no DOI, and doiget reaches PubMed records through their DOI",
id.display()
)),
Self::NoRecord => Some(format!("PubMed has no record for {}", id.display())),
}
}
}
pub async fn lookup(id: &PubmedId, ctx: &FetchContext) -> Result<Lookup, FetchError> {
let (db, uid) = id.db_and_id();
let raw_base = std::env::var(NCBI_BASE_ENV).unwrap_or_else(|_| NCBI_DEFAULT.to_string());
let mut base = Url::parse(&raw_base).map_err(|e| FetchError::SourceSchema {
hint: format!("{NCBI_BASE_ENV}={raw_base:?} is not a URL: {e}"),
})?;
if !base.path().ends_with('/') {
base.set_path(&format!("{}/", base.path()));
}
let mut url = base
.join("esummary.fcgi")
.map_err(|e| FetchError::SourceSchema {
hint: format!("building the E-utilities URL: {e}"),
})?;
{
let mut q = url.query_pairs_mut();
q.append_pair("db", db)
.append_pair("id", uid)
.append_pair("retmode", "json")
.append_pair("tool", "doiget");
if let Some(email) = crate::orchestrator::configured_contact_email() {
q.append_pair("email", &email);
}
}
let _permit = ctx.rate_limiter.acquire(NCBI).await;
let shown = id.display();
let row = |result, size, error_code| RowInput {
event: LogEvent::Resolve,
result,
capability: Capability::Metadata,
ref_: Some(shown.as_str()),
source: Some(NCBI),
error_code,
size_bytes: size,
license: None,
store_path: None,
canonical_digest: None,
};
let body = match ctx.http.fetch_bytes(NCBI, url).await {
Ok((body, _)) => body,
Err(e) => {
let e = FetchError::Http(e);
let code = crate::ErrorCode::from(&e);
ctx.log
.append(row(LogResult::Err, None, Some(code.as_wire())))?;
return Err(e);
}
};
let v: Value = serde_json::from_slice(&body).map_err(|e| FetchError::SourceSchema {
hint: format!("E-utilities returned non-JSON for {shown}: {e}"),
})?;
let found = classify(&v, uid);
ctx.log.append(row(
LogResult::Ok,
Some(body.len() as u64),
matches!(found, Lookup::NoRecord).then_some("NOT_FOUND"),
))?;
Ok(found)
}
fn classify(v: &Value, uid: &str) -> Lookup {
let Some(rec) = v.pointer("/result").and_then(|r| r.get(uid)) else {
return Lookup::NoRecord;
};
if rec.get("error").is_some() {
return Lookup::NoRecord;
}
rec.get("articleids")
.and_then(Value::as_array)
.and_then(|ids| {
ids.iter()
.find(|i| i.get("idtype").and_then(Value::as_str) == Some("doi"))
})
.and_then(|i| i.get("value").and_then(Value::as_str))
.and_then(|d| Doi::parse(d.trim()).ok())
.map_or(Lookup::NoDoi, Lookup::Doi)
}
#[derive(Debug, Clone)]
pub struct Unresolved {
pub id: PubmedId,
pub entry_key: Option<String>,
pub reason: String,
pub code: crate::ErrorCode,
}
#[derive(Debug)]
pub enum Resolved {
Entry(Result<ParsedEntry, ParseError>),
Unresolved(Unresolved),
}
pub async fn resolve_entries(
entries: Vec<Result<ParsedEntry, ParseError>>,
ctx: &FetchContext,
) -> Result<Vec<Resolved>, FetchError> {
let mut out = Vec::with_capacity(entries.len());
for entry in entries {
let (id, entry_key) = match &entry {
Err(ParseError::UnsupportedIdentifier {
kind,
value,
entry_key,
}) => match PubmedId::from_kind(kind, value) {
Some(id) => (id, entry_key.clone()),
None => {
out.push(Resolved::Entry(entry));
continue;
}
},
_ => {
out.push(Resolved::Entry(entry));
continue;
}
};
out.push(match lookup(&id, ctx).await {
Ok(Lookup::Doi(doi)) => Resolved::Entry(Ok(ParsedEntry {
ref_: Ref::Doi(doi),
entry_key,
})),
Ok(found) => Resolved::Unresolved(Unresolved {
reason: found.reason(&id).unwrap_or_default(),
code: if found == Lookup::NoRecord {
crate::ErrorCode::NotFound
} else {
crate::ErrorCode::NotImplemented
},
id,
entry_key,
}),
Err(FetchError::Log(e)) => return Err(FetchError::Log(e)),
Err(e) => Resolved::Unresolved(Unresolved {
reason: format!("looking up {} at NCBI failed: {e}", id.display()),
code: crate::ErrorCode::from(&e),
id,
entry_key,
}),
});
}
Ok(out)
}
pub async fn lookup_in_session(id: &PubmedId, ctx: &FetchContext) -> Result<Lookup, FetchError> {
bookend(ctx, LogEvent::SessionStart, LogResult::Ok)?;
let found = lookup(id, ctx).await;
bookend(
ctx,
LogEvent::SessionEnd,
if found.is_ok() {
LogResult::Ok
} else {
LogResult::Err
},
)?;
found
}
pub async fn resolve_entries_in_session(
entries: Vec<Result<ParsedEntry, ParseError>>,
ctx: &FetchContext,
) -> Result<Vec<Resolved>, FetchError> {
let any = entries.iter().any(|e| {
matches!(e, Err(ParseError::UnsupportedIdentifier { kind, value, .. })
if PubmedId::from_kind(kind, value).is_some())
});
if !any {
return Ok(entries.into_iter().map(Resolved::Entry).collect());
}
bookend(ctx, LogEvent::SessionStart, LogResult::Ok)?;
let resolved = resolve_entries(entries, ctx).await;
bookend(
ctx,
LogEvent::SessionEnd,
if resolved.is_ok() {
LogResult::Ok
} else {
LogResult::Err
},
)?;
resolved
}
fn bookend(ctx: &FetchContext, event: LogEvent, result: LogResult) -> Result<(), FetchError> {
ctx.log.append(RowInput {
event,
result,
capability: Capability::Metadata,
ref_: None,
source: Some(NCBI),
error_code: None,
size_bytes: None,
license: None,
store_path: None,
canonical_digest: None,
})?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn pubmed_ids_parse_in_their_written_forms_and_bare_digits_do_not() {
for (input, want) in [
("pmid:9659853", PubmedId::Pmid(Digits("9659853".into()))),
("PMID: 9659853", PubmedId::Pmid(Digits("9659853".into()))),
(
"pmcid:PMC3531190",
PubmedId::Pmcid(Digits("3531190".into())),
),
("PMC3531190", PubmedId::Pmcid(Digits("3531190".into()))),
(
"https://pubmed.ncbi.nlm.nih.gov/9659853/",
PubmedId::Pmid(Digits("9659853".into())),
),
(
"https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3531190/",
PubmedId::Pmcid(Digits("3531190".into())),
),
(
"https://pmc.ncbi.nlm.nih.gov/articles/PMC3531190/",
PubmedId::Pmcid(Digits("3531190".into())),
),
] {
assert_eq!(PubmedId::parse(input), Some(want), "{input}");
}
for no in [
"9659853",
"pmid:",
"pmid:12a",
"10.1176/ajp.155.7.895",
"https://example.org/9659853",
] {
assert_eq!(PubmedId::parse(no), None, "{no}");
}
assert_eq!(
PubmedId::from_kind("PMCID", "PMC3531190"),
Some(PubmedId::Pmcid(Digits("3531190".into())))
);
assert_eq!(PubmedId::from_kind("ISBN", "x"), None);
}
#[test]
fn an_esummary_answer_gives_the_doi_no_doi_or_no_record() {
let with_doi = serde_json::json!({"result": {"uids": ["9659853"], "9659853": {
"articleids": [
{"idtype": "pubmed", "value": "9659853"},
{"idtype": "doi", "value": "10.1176/ajp.155.7.895"}
]}}});
assert_eq!(
classify(&with_doi, "9659853"),
Lookup::Doi(Doi::parse("10.1176/ajp.155.7.895").unwrap())
);
let no_doi = serde_json::json!({"result": {"uids": ["1"], "1": {
"articleids": [{"idtype": "pubmed", "value": "1"}]}}});
assert_eq!(classify(&no_doi, "1"), Lookup::NoDoi);
let missing = serde_json::json!({"result": {"uids": ["99"], "99": {
"uid": "99", "error": "cannot get document summary"}}});
assert_eq!(classify(&missing, "99"), Lookup::NoRecord);
let spaced = serde_json::json!({"result": {"uids": ["2"], "2": {
"articleids": [{"idtype": "doi", "value": " 10.1176/ajp.155.7.895 "}]}}});
assert!(
matches!(classify(&spaced, "2"), Lookup::Doi(_)),
"a spaced DOI is a DOI"
);
}
}