doiget-core 0.9.0

Core library: Source/Store traits, CapabilityProfile, safekey, provenance log
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
//! Every source doiget knows, whether or not this binary was built with it,
//! and whether it can serve a given ref right now (#605).
//!
//! `fetch --dry-run` lists `candidate_hosts`, and its own help says that is
//! the static allowlist, "not a prediction". Before deciding whether to wait
//! for doiget or download a paper by hand, the question is different: can
//! **any** configured source deliver this DOI, and if not, why not -- not
//! built, not enabled, no credentials, or the publisher is not one it covers.
//! [`CATALOG`] is the list that answers it, and it exists in every build:
//! a default binary has no Tier-3 code (ADR-0002), but it can still say that
//! `tdm-aps` covers `10.1103` and needs `--features tdm-aps`.
//!
//! This is a statement about **reach**, never about outcome: a source that is
//! `Ready` for a DOI may still find nothing. The coverage report says so.

use crate::{CapabilityProfile, Ref};

/// What a source can contribute to a fetch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
    /// Bibliographic metadata only; never a PDF.
    Metadata,
    /// Where an OA copy lives -- a URL the `oa-publisher` leg then fetches.
    OaLocation,
    /// The PDF itself.
    Content,
}

impl Role {
    /// Wire token.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Metadata => "metadata",
            Self::OaLocation => "oa_location",
            Self::Content => "content",
        }
    }
}

/// Which refs a source is able to answer for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Covers {
    /// Any DOI.
    AnyDoi,
    /// arXiv ids (and DOIs through the preprint fallback).
    Arxiv,
    /// DOIs registered with DataCite (Zenodo, figshare, Dryad, OSF, ...).
    DataCiteDois,
    /// DOIs under these registrant prefixes only (ADR-0041).
    Prefixes(&'static [&'static str]),
}

/// One catalog row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SourceInfo {
    /// Source key, as in the attempt trace.
    pub name: &'static str,
    /// Tier 1 (always on), 2 (opt-in, no key), 3 (TDM agreement + key).
    pub tier: u8,
    /// What it contributes.
    pub role: Role,
    /// Which refs it answers for.
    pub covers: Covers,
    /// The publisher a prefix-scoped source belongs to.
    pub publisher: Option<&'static str>,
    /// Cargo feature the source is compiled under, if any.
    pub feature: Option<&'static str>,
    /// Environment the source needs at run time (enable flag, key, agreement).
    pub enable: &'static [&'static str],
    /// Whether this binary contains the source.
    pub compiled: bool,
}

const fn t1(name: &'static str, role: Role, covers: Covers) -> SourceInfo {
    SourceInfo {
        name,
        tier: 1,
        role,
        covers,
        publisher: None,
        feature: None,
        enable: &[],
        compiled: true,
    }
}

const fn t2(
    name: &'static str,
    role: Role,
    covers: Covers,
    enable: &'static [&'static str],
) -> SourceInfo {
    SourceInfo {
        name,
        tier: 2,
        role,
        covers,
        publisher: None,
        feature: Some("metadata"),
        enable,
        compiled: cfg!(feature = "metadata"),
    }
}

/// Every source, in the order a fetch consults them.
pub const CATALOG: &[SourceInfo] = &[
    t1("crossref", Role::Metadata, Covers::AnyDoi),
    t1("unpaywall", Role::OaLocation, Covers::AnyDoi),
    t1("oa-publisher", Role::Content, Covers::AnyDoi),
    t1("arxiv", Role::Content, Covers::Arxiv),
    t2(
        "datacite",
        Role::Metadata,
        Covers::DataCiteDois,
        &["DOIGET_ENABLE_DATACITE"],
    ),
    t2(
        "europe-pmc",
        Role::OaLocation,
        Covers::AnyDoi,
        &["DOIGET_ENABLE_EUROPE_PMC"],
    ),
    t2(
        "openaire",
        Role::OaLocation,
        Covers::AnyDoi,
        &["DOIGET_ENABLE_OPENAIRE"],
    ),
    t2(
        "hal",
        Role::OaLocation,
        Covers::AnyDoi,
        &["DOIGET_ENABLE_HAL"],
    ),
    t2(
        "core",
        Role::OaLocation,
        Covers::AnyDoi,
        &["DOIGET_ENABLE_CORE", "DOIGET_CORE_API_KEY"],
    ),
    t2(
        "openalex",
        Role::OaLocation,
        Covers::AnyDoi,
        &["DOIGET_ENABLE_OPENALEX"],
    ),
    t2(
        "semantic_scholar",
        Role::Metadata,
        Covers::AnyDoi,
        &["DOIGET_ENABLE_S2"],
    ),
    t2(
        "ads",
        Role::OaLocation,
        Covers::AnyDoi,
        &["DOIGET_ADS_TOKEN"],
    ),
    t2(
        "inspire",
        Role::OaLocation,
        Covers::AnyDoi,
        &["DOIGET_ENABLE_INSPIRE"],
    ),
    t2(
        "biorxiv",
        Role::OaLocation,
        Covers::AnyDoi,
        &["DOIGET_ENABLE_BIORXIV"],
    ),
    t2(
        "doaj",
        Role::Metadata,
        Covers::AnyDoi,
        &["DOIGET_ENABLE_DOAJ"],
    ),
    SourceInfo {
        name: "tdm-aps",
        tier: 3,
        role: Role::Content,
        covers: Covers::Prefixes(&["10.1103"]),
        publisher: Some("American Physical Society (APS)"),
        feature: Some("tdm-aps"),
        enable: &["DOIGET_KEY_APS", "DOIGET_AGREE_TDM_APS"],
        compiled: cfg!(feature = "tdm-aps"),
    },
    SourceInfo {
        name: "tdm-elsevier",
        tier: 3,
        role: Role::Content,
        covers: Covers::Prefixes(&["10.1016", "10.1006", "10.1053"]),
        publisher: Some("Elsevier BV"),
        feature: Some("tdm-elsevier"),
        enable: &["DOIGET_KEY_ELSEVIER", "DOIGET_AGREE_TDM_ELSEVIER"],
        compiled: cfg!(feature = "tdm-elsevier"),
    },
    SourceInfo {
        name: "tdm-springer",
        tier: 3,
        role: Role::Content,
        covers: Covers::Prefixes(&["10.1007", "10.1038", "10.1057", "10.1140"]),
        publisher: Some("Springer Nature"),
        feature: Some("tdm-springer"),
        enable: &["DOIGET_KEY_SPRINGER", "DOIGET_AGREE_TDM_SPRINGER"],
        compiled: cfg!(feature = "tdm-springer"),
    },
    SourceInfo {
        name: "tdm-ieee",
        tier: 3,
        role: Role::Content,
        covers: Covers::Prefixes(&["10.1109", "10.23919"]),
        publisher: Some("IEEE"),
        feature: Some("tdm-ieee"),
        enable: &["DOIGET_KEY_IEEE", "DOIGET_AGREE_TDM_IEEE"],
        compiled: cfg!(feature = "tdm-ieee"),
    },
];

/// Whether a source can be asked about a ref right now.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Availability {
    /// Compiled, enabled, and covers the ref. Says nothing about whether it
    /// will find anything.
    Ready,
    /// This binary was built without it.
    NotBuilt {
        /// The Cargo feature to build with.
        feature: &'static str,
    },
    /// Built, but its environment is not set.
    NotEnabled {
        /// What to set.
        enable: &'static [&'static str],
    },
    /// Built and enabled, but the ref is outside what it covers.
    NotCovered,
}

impl Availability {
    /// Wire token.
    #[must_use]
    pub const fn as_str(&self) -> &'static str {
        match self {
            Self::Ready => "ready",
            Self::NotBuilt { .. } => "not_built",
            Self::NotEnabled { .. } => "not_enabled",
            Self::NotCovered => "not_covered",
        }
    }

    /// One line a person can act on.
    #[must_use]
    pub fn remedy(&self) -> Option<String> {
        match self {
            Self::Ready | Self::NotCovered => None,
            Self::NotBuilt { feature } => Some(format!("build with --features {feature}")),
            Self::NotEnabled { enable } => Some(format!("set {}", enable.join(" and "))),
        }
    }
}

/// Whether `info` covers `ref_` by its own scope, ignoring build and config.
/// A DataCite-only source is reported as covering every DOI: which agency
/// registered a DOI is only known by asking.
#[must_use]
pub fn covers(info: &SourceInfo, ref_: &Ref) -> bool {
    match (info.covers, ref_) {
        // A DOI reaches arXiv only through the preprint fallback (#325), and
        // only when Unpaywall named the arXiv copy -- which `coverage`
        // already reports as the open copy. On its own it does not cover one.
        (Covers::Arxiv, Ref::Arxiv(_)) => true,
        (Covers::Arxiv, Ref::Doi(_)) | (_, Ref::Arxiv(_)) => false,
        (Covers::AnyDoi | Covers::DataCiteDois, Ref::Doi(_)) => true,
        (Covers::Prefixes(p), Ref::Doi(d)) => {
            let prefix = d.as_str().split('/').next().unwrap_or("");
            p.contains(&prefix)
        }
    }
}

/// Build and configuration state of `info`, with no ref to be in scope for:
/// [`Availability::NotBuilt`], [`Availability::NotEnabled`] or
/// [`Availability::Ready`] -- never `NotCovered`.
#[must_use]
pub fn configured(info: &SourceInfo, profile: &CapabilityProfile) -> Availability {
    if !info.compiled {
        Availability::NotBuilt {
            feature: info.feature.unwrap_or("?"),
        }
    } else if !enabled(info.name, profile) {
        Availability::NotEnabled {
            enable: info.enable,
        }
    } else {
        Availability::Ready
    }
}

/// [`Availability`] of `info` for `ref_` under `profile`.
#[must_use]
pub fn availability(info: &SourceInfo, profile: &CapabilityProfile, ref_: &Ref) -> Availability {
    match configured(info, profile) {
        Availability::Ready if !covers(info, ref_) => Availability::NotCovered,
        other => other,
    }
}

fn enabled(name: &str, p: &CapabilityProfile) -> bool {
    let m = &p.metadata;
    match name {
        "datacite" => m.datacite,
        "europe-pmc" => m.europe_pmc,
        "openaire" => m.openaire,
        "hal" => m.hal,
        "core" => m.core,
        "openalex" => m.openalex,
        "semantic_scholar" => m.semantic_scholar,
        "doaj" => m.doaj,
        "biorxiv" => m.biorxiv,
        "inspire" => m.inspire,
        "ads" => m.ads,
        "tdm-aps" => p.tdm_aps.is_some(),
        "tdm-elsevier" => p.tdm_elsevier.is_some(),
        "tdm-springer" => p.tdm_springer.is_some(),
        "tdm-ieee" => p.tdm_ieee.is_some(),
        _ => true,
    }
}

/// Catalog rows relevant to `publisher`: a registrant prefix (`10.1103`) or a
/// case-insensitive fragment of a publisher's name (`aps`, `springer`).
/// Sources that cover any DOI are always relevant.
#[must_use]
pub fn for_publisher(publisher: &str) -> Vec<&'static SourceInfo> {
    let q = publisher.trim().to_lowercase();
    CATALOG
        .iter()
        .filter(|s| match s.covers {
            Covers::Prefixes(p) => {
                p.iter().any(|x| q == *x || q.starts_with(&format!("{x}/")))
                    || s.publisher.is_some_and(|n| n.to_lowercase().contains(&q))
            }
            Covers::Arxiv => q == "arxiv" || q == "10.48550",
            _ => true,
        })
        .collect()
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
    use super::*;

    fn doi(s: &str) -> Ref {
        Ref::parse(s).expect("ref")
    }

    #[test]
    fn a_default_profile_names_the_switch_for_each_source_it_will_not_ask() {
        let p = CapabilityProfile::for_tests();
        let r = doi("10.1103/PhysRevB.48.10345");
        let by = |n: &str| CATALOG.iter().find(|s| s.name == n).expect("row");
        assert_eq!(availability(by("crossref"), &p, &r), Availability::Ready);
        let aps = availability(by("tdm-aps"), &p, &r);
        if cfg!(feature = "tdm-aps") {
            assert!(matches!(aps, Availability::NotEnabled { .. }));
        } else {
            assert_eq!(
                aps.remedy().as_deref(),
                Some("build with --features tdm-aps")
            );
        }
        if cfg!(feature = "metadata") {
            assert_eq!(
                availability(by("hal"), &p, &r).remedy().as_deref(),
                Some("set DOIGET_ENABLE_HAL")
            );
        }
    }

    #[test]
    fn a_publisher_scoped_source_covers_only_its_prefixes() {
        let aps = CATALOG.iter().find(|s| s.name == "tdm-aps").unwrap();
        assert!(covers(aps, &doi("10.1103/PhysRevB.48.10345")));
        assert!(!covers(aps, &doi("10.1007/BF01340294")));
        assert!(!covers(aps, &doi("arxiv:2401.00001")));
    }

    #[test]
    fn a_publisher_query_matches_prefix_or_name_and_keeps_general_sources() {
        let names = |q: &str| -> Vec<&str> { for_publisher(q).iter().map(|s| s.name).collect() };
        assert!(names("10.1103").contains(&"tdm-aps"));
        assert!(!names("10.1103").contains(&"tdm-springer"));
        assert!(names("Springer").contains(&"tdm-springer"));
        assert!(names("springer").contains(&"crossref"));
        assert!(!names("springer").contains(&"arxiv"));
    }

    /// The catalog repeats the TDM prefixes so a build without the source
    /// can still name them; it must never disagree with the source itself.
    #[test]
    fn the_catalog_prefixes_are_the_sources_own() {
        #[allow(unused_mut)]
        let mut pairs: Vec<(&str, &[&str])> = Vec::new();
        #[cfg(feature = "tdm-aps")]
        pairs.push(("tdm-aps", crate::sources::tdm_aps::PUBLISHER_PREFIXES));
        #[cfg(feature = "tdm-elsevier")]
        pairs.push((
            "tdm-elsevier",
            crate::sources::tdm_elsevier::PUBLISHER_PREFIXES,
        ));
        #[cfg(feature = "tdm-springer")]
        pairs.push((
            "tdm-springer",
            crate::sources::tdm_springer::PUBLISHER_PREFIXES,
        ));
        #[cfg(feature = "tdm-ieee")]
        pairs.push(("tdm-ieee", crate::sources::tdm_ieee::PUBLISHER_PREFIXES));
        for (name, own) in pairs {
            let row = CATALOG.iter().find(|s| s.name == name).expect("row");
            assert_eq!(row.covers, Covers::Prefixes(own), "{name}");
        }
    }

    #[test]
    fn arxiv_covers_arxiv_ids_and_not_dois() {
        let arxiv = CATALOG.iter().find(|s| s.name == "arxiv").unwrap();
        assert!(covers(arxiv, &doi("arXiv:cond-mat/0409292")));
        assert!(!covers(arxiv, &doi("10.1038/nphys1170")));
    }

    /// Every module under `src/sources/` has a catalog row, so a new source
    /// cannot ship invisible to `doiget sources` (DOAJ once did).
    #[test]
    fn every_source_module_has_a_catalog_row() {
        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/sources");
        for entry in std::fs::read_dir(dir).expect("sources dir") {
            let file = entry.expect("entry").file_name();
            let stem = file.to_str().unwrap().trim_end_matches(".rs");
            let name = match stem {
                "mod" => continue,
                "core_oa" => "core",
                "europepmc" => "europe-pmc",
                "s2" => "semantic_scholar",
                other => &other.replace('_', "-"),
            };
            assert!(
                CATALOG.iter().any(|s| s.name == name),
                "src/sources/{stem}.rs has no CATALOG row named {name:?}"
            );
        }
    }
}