1use crate::{CapabilityProfile, Ref};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Role {
21 Metadata,
23 OaLocation,
25 Content,
27}
28
29impl Role {
30 #[must_use]
32 pub const fn as_str(self) -> &'static str {
33 match self {
34 Self::Metadata => "metadata",
35 Self::OaLocation => "oa_location",
36 Self::Content => "content",
37 }
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Covers {
44 AnyDoi,
46 Arxiv,
48 DataCiteDois,
50 Prefixes(&'static [&'static str]),
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct SourceInfo {
57 pub name: &'static str,
59 pub tier: u8,
61 pub role: Role,
63 pub covers: Covers,
65 pub publisher: Option<&'static str>,
67 pub feature: Option<&'static str>,
69 pub enable: &'static [&'static str],
71 pub compiled: bool,
73}
74
75const fn t1(name: &'static str, role: Role, covers: Covers) -> SourceInfo {
76 SourceInfo {
77 name,
78 tier: 1,
79 role,
80 covers,
81 publisher: None,
82 feature: None,
83 enable: &[],
84 compiled: true,
85 }
86}
87
88const fn t2(
89 name: &'static str,
90 role: Role,
91 covers: Covers,
92 enable: &'static [&'static str],
93) -> SourceInfo {
94 SourceInfo {
95 name,
96 tier: 2,
97 role,
98 covers,
99 publisher: None,
100 feature: Some("metadata"),
101 enable,
102 compiled: cfg!(feature = "metadata"),
103 }
104}
105
106pub const CATALOG: &[SourceInfo] = &[
108 t1("crossref", Role::Metadata, Covers::AnyDoi),
109 t1("unpaywall", Role::OaLocation, Covers::AnyDoi),
110 t1("oa-publisher", Role::Content, Covers::AnyDoi),
111 t1("arxiv", Role::Content, Covers::Arxiv),
112 t2(
113 "datacite",
114 Role::Metadata,
115 Covers::DataCiteDois,
116 &["DOIGET_ENABLE_DATACITE"],
117 ),
118 t2(
119 "europe-pmc",
120 Role::OaLocation,
121 Covers::AnyDoi,
122 &["DOIGET_ENABLE_EUROPE_PMC"],
123 ),
124 t2(
125 "openaire",
126 Role::OaLocation,
127 Covers::AnyDoi,
128 &["DOIGET_ENABLE_OPENAIRE"],
129 ),
130 t2(
131 "hal",
132 Role::OaLocation,
133 Covers::AnyDoi,
134 &["DOIGET_ENABLE_HAL"],
135 ),
136 t2(
137 "core",
138 Role::OaLocation,
139 Covers::AnyDoi,
140 &["DOIGET_ENABLE_CORE", "DOIGET_CORE_API_KEY"],
141 ),
142 t2(
143 "openalex",
144 Role::OaLocation,
145 Covers::AnyDoi,
146 &["DOIGET_ENABLE_OPENALEX"],
147 ),
148 t2(
149 "semantic_scholar",
150 Role::Metadata,
151 Covers::AnyDoi,
152 &["DOIGET_ENABLE_S2"],
153 ),
154 t2(
155 "ads",
156 Role::OaLocation,
157 Covers::AnyDoi,
158 &["DOIGET_ADS_TOKEN"],
159 ),
160 t2(
161 "inspire",
162 Role::OaLocation,
163 Covers::AnyDoi,
164 &["DOIGET_ENABLE_INSPIRE"],
165 ),
166 t2(
167 "biorxiv",
168 Role::OaLocation,
169 Covers::AnyDoi,
170 &["DOIGET_ENABLE_BIORXIV"],
171 ),
172 t2(
173 "doaj",
174 Role::Metadata,
175 Covers::AnyDoi,
176 &["DOIGET_ENABLE_DOAJ"],
177 ),
178 SourceInfo {
179 name: "tdm-aps",
180 tier: 3,
181 role: Role::Content,
182 covers: Covers::Prefixes(&["10.1103"]),
183 publisher: Some("American Physical Society (APS)"),
184 feature: Some("tdm-aps"),
185 enable: &["DOIGET_KEY_APS", "DOIGET_AGREE_TDM_APS"],
186 compiled: cfg!(feature = "tdm-aps"),
187 },
188 SourceInfo {
189 name: "tdm-elsevier",
190 tier: 3,
191 role: Role::Content,
192 covers: Covers::Prefixes(&["10.1016", "10.1006", "10.1053"]),
193 publisher: Some("Elsevier BV"),
194 feature: Some("tdm-elsevier"),
195 enable: &["DOIGET_KEY_ELSEVIER", "DOIGET_AGREE_TDM_ELSEVIER"],
196 compiled: cfg!(feature = "tdm-elsevier"),
197 },
198 SourceInfo {
199 name: "tdm-springer",
200 tier: 3,
201 role: Role::Content,
202 covers: Covers::Prefixes(&["10.1007", "10.1038", "10.1057", "10.1140"]),
203 publisher: Some("Springer Nature"),
204 feature: Some("tdm-springer"),
205 enable: &["DOIGET_KEY_SPRINGER", "DOIGET_AGREE_TDM_SPRINGER"],
206 compiled: cfg!(feature = "tdm-springer"),
207 },
208 SourceInfo {
209 name: "tdm-ieee",
210 tier: 3,
211 role: Role::Content,
212 covers: Covers::Prefixes(&["10.1109", "10.23919"]),
213 publisher: Some("IEEE"),
214 feature: Some("tdm-ieee"),
215 enable: &["DOIGET_KEY_IEEE", "DOIGET_AGREE_TDM_IEEE"],
216 compiled: cfg!(feature = "tdm-ieee"),
217 },
218];
219
220#[derive(Debug, Clone, PartialEq, Eq)]
222pub enum Availability {
223 Ready,
226 NotBuilt {
228 feature: &'static str,
230 },
231 NotEnabled {
233 enable: &'static [&'static str],
235 },
236 NotCovered,
238}
239
240impl Availability {
241 #[must_use]
243 pub const fn as_str(&self) -> &'static str {
244 match self {
245 Self::Ready => "ready",
246 Self::NotBuilt { .. } => "not_built",
247 Self::NotEnabled { .. } => "not_enabled",
248 Self::NotCovered => "not_covered",
249 }
250 }
251
252 #[must_use]
254 pub fn remedy(&self) -> Option<String> {
255 match self {
256 Self::Ready | Self::NotCovered => None,
257 Self::NotBuilt { feature } => Some(format!("build with --features {feature}")),
258 Self::NotEnabled { enable } => Some(format!("set {}", enable.join(" and "))),
259 }
260 }
261}
262
263#[must_use]
267pub fn covers(info: &SourceInfo, ref_: &Ref) -> bool {
268 match (info.covers, ref_) {
269 (Covers::Arxiv, Ref::Arxiv(_)) => true,
273 (Covers::Arxiv, Ref::Doi(_)) | (_, Ref::Arxiv(_)) => false,
274 (Covers::AnyDoi | Covers::DataCiteDois, Ref::Doi(_)) => true,
275 (Covers::Prefixes(p), Ref::Doi(d)) => {
276 let prefix = d.as_str().split('/').next().unwrap_or("");
277 p.contains(&prefix)
278 }
279 }
280}
281
282#[must_use]
286pub fn configured(info: &SourceInfo, profile: &CapabilityProfile) -> Availability {
287 if !info.compiled {
288 Availability::NotBuilt {
289 feature: info.feature.unwrap_or("?"),
290 }
291 } else if !enabled(info.name, profile) {
292 Availability::NotEnabled {
293 enable: info.enable,
294 }
295 } else {
296 Availability::Ready
297 }
298}
299
300#[must_use]
302pub fn availability(info: &SourceInfo, profile: &CapabilityProfile, ref_: &Ref) -> Availability {
303 match configured(info, profile) {
304 Availability::Ready if !covers(info, ref_) => Availability::NotCovered,
305 other => other,
306 }
307}
308
309fn enabled(name: &str, p: &CapabilityProfile) -> bool {
310 let m = &p.metadata;
311 match name {
312 "datacite" => m.datacite,
313 "europe-pmc" => m.europe_pmc,
314 "openaire" => m.openaire,
315 "hal" => m.hal,
316 "core" => m.core,
317 "openalex" => m.openalex,
318 "semantic_scholar" => m.semantic_scholar,
319 "doaj" => m.doaj,
320 "biorxiv" => m.biorxiv,
321 "inspire" => m.inspire,
322 "ads" => m.ads,
323 "tdm-aps" => p.tdm_aps.is_some(),
324 "tdm-elsevier" => p.tdm_elsevier.is_some(),
325 "tdm-springer" => p.tdm_springer.is_some(),
326 "tdm-ieee" => p.tdm_ieee.is_some(),
327 _ => true,
328 }
329}
330
331#[must_use]
335pub fn for_publisher(publisher: &str) -> Vec<&'static SourceInfo> {
336 let q = publisher.trim().to_lowercase();
337 CATALOG
338 .iter()
339 .filter(|s| match s.covers {
340 Covers::Prefixes(p) => {
341 p.iter().any(|x| q == *x || q.starts_with(&format!("{x}/")))
342 || s.publisher.is_some_and(|n| n.to_lowercase().contains(&q))
343 }
344 Covers::Arxiv => q == "arxiv" || q == "10.48550",
345 _ => true,
346 })
347 .collect()
348}
349
350#[cfg(test)]
351#[allow(clippy::expect_used, clippy::unwrap_used)]
352mod tests {
353 use super::*;
354
355 fn doi(s: &str) -> Ref {
356 Ref::parse(s).expect("ref")
357 }
358
359 #[test]
360 fn a_default_profile_names_the_switch_for_each_source_it_will_not_ask() {
361 let p = CapabilityProfile::for_tests();
362 let r = doi("10.1103/PhysRevB.48.10345");
363 let by = |n: &str| CATALOG.iter().find(|s| s.name == n).expect("row");
364 assert_eq!(availability(by("crossref"), &p, &r), Availability::Ready);
365 let aps = availability(by("tdm-aps"), &p, &r);
366 if cfg!(feature = "tdm-aps") {
367 assert!(matches!(aps, Availability::NotEnabled { .. }));
368 } else {
369 assert_eq!(
370 aps.remedy().as_deref(),
371 Some("build with --features tdm-aps")
372 );
373 }
374 if cfg!(feature = "metadata") {
375 assert_eq!(
376 availability(by("hal"), &p, &r).remedy().as_deref(),
377 Some("set DOIGET_ENABLE_HAL")
378 );
379 }
380 }
381
382 #[test]
383 fn a_publisher_scoped_source_covers_only_its_prefixes() {
384 let aps = CATALOG.iter().find(|s| s.name == "tdm-aps").unwrap();
385 assert!(covers(aps, &doi("10.1103/PhysRevB.48.10345")));
386 assert!(!covers(aps, &doi("10.1007/BF01340294")));
387 assert!(!covers(aps, &doi("arxiv:2401.00001")));
388 }
389
390 #[test]
391 fn a_publisher_query_matches_prefix_or_name_and_keeps_general_sources() {
392 let names = |q: &str| -> Vec<&str> { for_publisher(q).iter().map(|s| s.name).collect() };
393 assert!(names("10.1103").contains(&"tdm-aps"));
394 assert!(!names("10.1103").contains(&"tdm-springer"));
395 assert!(names("Springer").contains(&"tdm-springer"));
396 assert!(names("springer").contains(&"crossref"));
397 assert!(!names("springer").contains(&"arxiv"));
398 }
399
400 #[test]
403 fn the_catalog_prefixes_are_the_sources_own() {
404 #[allow(unused_mut)]
405 let mut pairs: Vec<(&str, &[&str])> = Vec::new();
406 #[cfg(feature = "tdm-aps")]
407 pairs.push(("tdm-aps", crate::sources::tdm_aps::PUBLISHER_PREFIXES));
408 #[cfg(feature = "tdm-elsevier")]
409 pairs.push((
410 "tdm-elsevier",
411 crate::sources::tdm_elsevier::PUBLISHER_PREFIXES,
412 ));
413 #[cfg(feature = "tdm-springer")]
414 pairs.push((
415 "tdm-springer",
416 crate::sources::tdm_springer::PUBLISHER_PREFIXES,
417 ));
418 #[cfg(feature = "tdm-ieee")]
419 pairs.push(("tdm-ieee", crate::sources::tdm_ieee::PUBLISHER_PREFIXES));
420 for (name, own) in pairs {
421 let row = CATALOG.iter().find(|s| s.name == name).expect("row");
422 assert_eq!(row.covers, Covers::Prefixes(own), "{name}");
423 }
424 }
425
426 #[test]
427 fn arxiv_covers_arxiv_ids_and_not_dois() {
428 let arxiv = CATALOG.iter().find(|s| s.name == "arxiv").unwrap();
429 assert!(covers(arxiv, &doi("arXiv:cond-mat/0409292")));
430 assert!(!covers(arxiv, &doi("10.1038/nphys1170")));
431 }
432
433 #[test]
436 fn every_source_module_has_a_catalog_row() {
437 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/sources");
438 for entry in std::fs::read_dir(dir).expect("sources dir") {
439 let file = entry.expect("entry").file_name();
440 let stem = file.to_str().unwrap().trim_end_matches(".rs");
441 let name = match stem {
442 "mod" => continue,
443 "core_oa" => "core",
444 "europepmc" => "europe-pmc",
445 "s2" => "semantic_scholar",
446 other => &other.replace('_', "-"),
447 };
448 assert!(
449 CATALOG.iter().any(|s| s.name == name),
450 "src/sources/{stem}.rs has no CATALOG row named {name:?}"
451 );
452 }
453 }
454}