1use std::collections::BTreeMap;
15
16use camino::{Utf8Path, Utf8PathBuf};
17use chrono::Utc;
18use serde_json::Value;
19
20use crate::dry_run::{build_fetch_plan, try_build_fetch_plan, FetchPlan};
21use crate::http::HttpError;
22use crate::provenance::{Capability, LogEvent, LogResult, RowInput};
23use crate::source::{FetchContext, FetchError, FetchResult, Source};
24use crate::sources::arxiv::ArxivSource;
25use crate::sources::crossref::CrossrefSource;
26use crate::sources::unpaywall::UnpaywallSource;
27use crate::store::{DoigetExtension, Metadata, Store};
28use crate::DenialContext;
29use crate::{ArxivId, CapabilityProfile, Doi, Ref, Safekey, MAX_BATCH_REFS, SCHEMA_VERSION};
30
31#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
44#[non_exhaustive]
45pub struct MetadataOnlyOutcome {
46 pub source: String,
50 pub resolver_profile: String,
57 pub license: Option<String>,
61 pub oa_url: Option<String>,
66 #[serde(default)]
75 pub oa_status: Option<String>,
76 pub metadata: Value,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub metadata_quality: Vec<String>,
89 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
92 pub repaired_fields: std::collections::BTreeMap<String, String>,
93}
94
95pub async fn metadata_only(
155 ref_: &Ref,
156 profile: &CapabilityProfile,
157 ctx: &FetchContext,
158) -> Result<MetadataOnlyOutcome, FetchError> {
159 metadata_only_with_options(ref_, profile, ctx, MetadataOnlyOptions::default()).await
160}
161
162#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
175#[non_exhaustive]
176pub struct MetadataOnlyOptions {
177 pub include_oa_location: bool,
205}
206
207impl MetadataOnlyOptions {
208 #[must_use]
214 pub const fn with_oa_location(mut self, include: bool) -> Self {
215 self.include_oa_location = include;
216 self
217 }
218}
219
220pub async fn metadata_only_with_options(
227 ref_: &Ref,
228 profile: &CapabilityProfile,
229 ctx: &FetchContext,
230 opts: MetadataOnlyOptions,
231) -> Result<MetadataOnlyOutcome, FetchError> {
232 let cache_root = if resolver_base_overridden() {
243 None
244 } else {
245 ctx.cache_root.as_deref()
246 };
247
248 if let Some(root) = cache_root {
253 if let Some(cached) = crate::resolver_cache::read_with_options(root, ref_, opts) {
254 return Ok(cached);
255 }
256 }
257
258 let outcome = match ref_ {
259 Ref::Doi(doi) => metadata_only_doi(doi, ref_, profile, ctx, opts).await?,
260 Ref::Arxiv(id) => {
261 let arxiv = arxiv_source_from_env();
262 let metadata = arxiv.fetch_metadata_only(id, ctx).await?;
263 MetadataOnlyOutcome {
266 metadata_quality: Vec::new(),
267 repaired_fields: std::collections::BTreeMap::new(),
268 source: arxiv.name().to_string(),
269 resolver_profile: arxiv.name().to_string(),
270 license: Some("arxiv-default".to_string()),
271 oa_url: None,
272 oa_status: Some("green".to_string()),
274 metadata,
275 }
276 }
277 };
278
279 if let Some(root) = cache_root {
281 crate::resolver_cache::write_with_options(root, ref_, &outcome, opts);
282 }
283 Ok(outcome)
284}
285
286fn resolver_base_overridden() -> bool {
291 [
292 "DOIGET_CROSSREF_BASE",
293 "DOIGET_UNPAYWALL_BASE",
294 "DOIGET_ARXIV_BASE",
295 ]
296 .iter()
297 .any(|k| std::env::var_os(k).is_some())
298}
299
300pub async fn resolve_only(
341 ref_: &Ref,
342 profile: &CapabilityProfile,
343 ctx: &FetchContext,
344) -> Result<MetadataOnlyOutcome, FetchError> {
345 resolve_only_with_options(ref_, profile, ctx, MetadataOnlyOptions::default()).await
346}
347
348pub async fn resolve_only_with_options(
354 ref_: &Ref,
355 profile: &CapabilityProfile,
356 ctx: &FetchContext,
357 opts: MetadataOnlyOptions,
358) -> Result<MetadataOnlyOutcome, FetchError> {
359 metadata_only_with_options(ref_, profile, ctx, opts).await
366}
367
368pub async fn metadata_only_to_store(
395 ref_: &Ref,
396 profile: &CapabilityProfile,
397 ctx: &FetchContext,
398 store: &dyn Store,
399) -> Result<MetadataOnlyOutcome, FetchError> {
400 metadata_only_to_store_with_options(ref_, profile, ctx, store, MetadataOnlyOptions::default())
401 .await
402}
403
404pub async fn metadata_only_to_store_with_options(
411 ref_: &Ref,
412 profile: &CapabilityProfile,
413 ctx: &FetchContext,
414 store: &dyn Store,
415 opts: MetadataOnlyOptions,
416) -> Result<MetadataOnlyOutcome, FetchError> {
417 let outcome = metadata_only_with_options(ref_, profile, ctx, opts).await?;
418 let safekey = ref_.safekey();
419 let mut metadata = build_metadata_only_metadata(ref_, &outcome);
420 let quality = crate::metadata_quality::repair(&mut metadata, profile, ctx).await;
425 let mut outcome = outcome;
426 outcome.metadata_quality = quality.flags();
427 outcome.repaired_fields = quality.repaired;
428 write_metadata_and_pdf(store, &safekey, &metadata, None, ctx)?;
432 Ok(outcome)
433}
434
435fn build_metadata_only_metadata(ref_: &Ref, outcome: &MetadataOnlyOutcome) -> Metadata {
449 let (doi, arxiv_id) = match ref_ {
450 Ref::Doi(d) => (Some(d.clone()), None),
451 Ref::Arxiv(a) => (None, Some(a.clone())),
452 };
453 let ref_id = ref_.as_input_str().to_string();
454 let title = match extract_metadata_title(&outcome.metadata) {
455 Some(t) => t,
456 None => {
457 tracing::warn!(
462 ref_id = %ref_id,
463 source = %outcome.source,
464 "metadata-only: no usable title in resolver payload; \
465 persisting the ref id as the title placeholder"
466 );
467 ref_id
468 }
469 };
470 Metadata {
471 schema_version: SCHEMA_VERSION.to_string(),
472 title,
473 authors: extract_metadata_authors(&outcome.metadata),
474 year: None,
475 doi,
476 arxiv_id,
477 arxiv_categories: Vec::new(),
481 abstract_: None,
482 venue: None,
483 volume: None,
484 issue: None,
485 pages: None,
486 publisher: None,
487 issn: None,
488 isbn: None,
489 type_: None,
490 keywords: Vec::new(),
491 url: outcome.oa_url.clone(),
492 pdf_path: None,
493 doiget: Some(DoigetExtension {
494 fetched_at: Utc::now(),
495 source: outcome.source.clone(),
496 license: outcome
497 .license
498 .clone()
499 .unwrap_or_else(|| "unknown".to_string()),
500 oa_status: outcome.oa_status.clone(),
501 size_bytes: 0,
502 mcp_call_id: None,
503 tags: Vec::new(),
504 collections: Vec::new(),
505 annotation: None,
506 repaired_fields: Default::default(),
507 short_venue: None,
508 origin: None,
509 }),
510 other: BTreeMap::new(),
511 }
512}
513
514#[must_use]
529pub fn cite_metadata(ref_: &Ref, outcome: &MetadataOnlyOutcome) -> Metadata {
530 let mut m = build_metadata_only_metadata(ref_, outcome);
531 if outcome.source == "crossref" {
532 let f = extract_crossref_fields(&outcome.metadata);
533 if let Some(title) = f.title {
534 m.title = title;
535 }
536 if !f.authors.is_empty() {
537 m.authors = f.authors;
538 }
539 m.year = f.year;
540 m.venue = f.venue;
541 if let Some(d) = m.doiget.as_mut() {
542 d.short_venue = f.short_venue;
543 }
544 m.volume = f.volume;
545 m.issue = f.issue;
546 m.pages = f.pages;
547 m.type_ = f.type_;
548 m.publisher = outcome
549 .metadata
550 .get("publisher")
551 .and_then(Value::as_str)
552 .map(str::to_string);
553 m.issn = outcome
555 .metadata
556 .get("ISSN")
557 .and_then(Value::as_array)
558 .and_then(|a| a.first())
559 .and_then(Value::as_str)
560 .map(str::to_string);
561 } else if outcome.source == "datacite" {
562 cite_datacite(&mut m, &outcome.metadata);
563 } else if outcome.source == "arxiv" {
564 m.year = outcome
572 .metadata
573 .get("published")
574 .and_then(Value::as_str)
575 .and_then(parse_rfc3339_year);
576 m.arxiv_categories = extract_arxiv_categories(&outcome.metadata);
577 }
578 m
579}
580
581#[cfg(feature = "metadata")]
586fn cite_datacite(m: &mut Metadata, attributes: &Value) {
587 let f = extract_datacite_fields(attributes);
588 if let Some(title) = f.title {
589 m.title = title;
590 }
591 if !f.authors.is_empty() {
592 m.authors = f.authors;
593 }
594 m.year = f.year.or(m.year);
595 m.publisher = f.venue;
596 m.type_ = f.type_;
597 if let Some(v) = attributes.get("version").and_then(Value::as_str) {
598 m.other
599 .insert("version".into(), toml::Value::String(v.to_string()));
600 }
601 m.url = attributes
602 .get("url")
603 .and_then(Value::as_str)
604 .map(str::to_string);
605}
606
607#[cfg(not(feature = "metadata"))]
608fn cite_datacite(_: &mut Metadata, _: &Value) {}
609
610fn parse_rfc3339_year(s: &str) -> Option<i32> {
615 chrono::DateTime::parse_from_rfc3339(s)
616 .ok()
617 .map(|dt| chrono::Datelike::year(&dt))
618}
619
620fn extract_arxiv_categories(atom: &Value) -> Vec<String> {
626 atom.get("categories")
627 .and_then(Value::as_array)
628 .map(|a| {
629 a.iter()
630 .filter_map(Value::as_str)
631 .map(str::to_string)
632 .collect()
633 })
634 .unwrap_or_default()
635}
636
637fn normalize_page_range(page: &str) -> String {
643 if page.contains("--") || !page.contains('-') {
644 return page.to_string();
645 }
646 page.replace('-', "--")
647}
648
649fn extract_metadata_title(meta: &Value) -> Option<String> {
654 let t = meta.get("title")?;
655 let s = match t.as_str() {
656 Some(s) => s.trim().to_string(),
657 None => t
658 .as_array()?
659 .iter()
660 .filter_map(Value::as_str)
661 .map(str::trim)
662 .find(|s| !s.is_empty())?
663 .to_string(),
664 };
665 if s.is_empty() {
666 None
667 } else {
668 Some(crate::markup::plain_title(&s))
669 }
670}
671
672fn extract_metadata_authors(meta: &Value) -> Vec<String> {
683 if let Some(arr) = meta.get("authors").and_then(Value::as_array) {
684 let v: Vec<String> = arr
685 .iter()
686 .filter_map(|a| a.as_str().map(str::to_string))
687 .collect();
688 if !v.is_empty() {
689 return v;
690 }
691 }
692 for key in ["author", "z_authors"] {
693 if let Some(arr) = meta.get(key).and_then(Value::as_array) {
694 let v: Vec<String> = arr
695 .iter()
696 .filter_map(|a| {
697 let given = a.get("given").and_then(Value::as_str).unwrap_or("");
698 let family = a.get("family").and_then(Value::as_str).unwrap_or("");
699 let name = format!("{given} {family}");
700 let name = name.trim();
701 if name.is_empty() {
702 a.get("name").and_then(Value::as_str).map(str::to_string)
703 } else {
704 Some(name.to_string())
705 }
706 })
707 .collect();
708 if !v.is_empty() {
709 return v;
710 }
711 }
712 }
713 Vec::new()
714}
715
716const FALLBACK_CONTACT_EMAIL: &str = "doiget@localhost";
729
730fn env_nonempty(key: &str) -> Option<String> {
732 std::env::var(key).ok().filter(|s| !s.trim().is_empty())
733}
734
735pub(crate) fn resolve_contact_email() -> String {
746 contact_email_or_placeholder()
747}
748
749#[must_use]
763pub fn configured_contact_email() -> Option<String> {
764 env_nonempty("DOIGET_CONTACT_EMAIL")
765 .or_else(|| crate::user_extension::load_or_default().contact_email)
766}
767
768#[must_use]
771pub fn contact_email_or_placeholder() -> String {
772 configured_contact_email().unwrap_or_else(|| FALLBACK_CONTACT_EMAIL.to_string())
773}
774
775struct ContactAddresses {
783 crossref: String,
785 unpaywall: String,
787}
788
789fn resolve_contact_emails() -> ContactAddresses {
796 let env_contact = env_nonempty("DOIGET_CONTACT_EMAIL");
797 let env_unpaywall = env_nonempty("DOIGET_UNPAYWALL_EMAIL");
798 if let (Some(crossref), Some(unpaywall)) = (&env_contact, &env_unpaywall) {
800 return ContactAddresses {
801 crossref: crossref.clone(),
802 unpaywall: unpaywall.clone(),
803 };
804 }
805 let file = crate::user_extension::load_or_default();
806 let crossref = env_contact
807 .or(file.contact_email)
808 .unwrap_or_else(|| FALLBACK_CONTACT_EMAIL.to_string());
809 let unpaywall = env_unpaywall
810 .or(file.unpaywall_email)
811 .unwrap_or_else(|| crossref.clone());
812 ContactAddresses {
813 crossref,
814 unpaywall,
815 }
816}
817
818fn arxiv_source_from_env() -> ArxivSource {
819 if let Ok(s) = std::env::var("DOIGET_ARXIV_BASE") {
820 match url::Url::parse(&s) {
821 Ok(url) => return ArxivSource::with_base(url),
822 Err(e) => tracing::warn!(
823 value = %s,
824 error = %e,
825 "DOIGET_ARXIV_BASE is not a valid URL; using the default arXiv base"
826 ),
827 }
828 }
829 ArxivSource::new()
830}
831
832fn crossref_source_from_env(contact: &str) -> CrossrefSource {
833 if let Ok(s) = std::env::var("DOIGET_CROSSREF_BASE") {
834 match url::Url::parse(&s) {
835 Ok(url) => return CrossrefSource::with_base(url, contact.to_string()),
836 Err(e) => tracing::warn!(
837 value = %s,
838 error = %e,
839 "DOIGET_CROSSREF_BASE is not a valid URL; using the default Crossref base"
840 ),
841 }
842 }
843 CrossrefSource::new(contact.to_string())
844}
845
846fn unpaywall_source_from_env(contact: &str) -> UnpaywallSource {
847 if let Ok(s) = std::env::var("DOIGET_UNPAYWALL_BASE") {
848 match url::Url::parse(&s) {
849 Ok(url) => return UnpaywallSource::with_base(url, contact.to_string()),
850 Err(e) => tracing::warn!(
851 value = %s,
852 error = %e,
853 "DOIGET_UNPAYWALL_BASE is not a valid URL; using the default Unpaywall base"
854 ),
855 }
856 }
857 UnpaywallSource::new(contact.to_string())
858}
859
860async fn metadata_only_doi(
872 _doi: &Doi,
873 ref_: &Ref,
874 profile: &CapabilityProfile,
875 ctx: &FetchContext,
876 opts: MetadataOnlyOptions,
877) -> Result<MetadataOnlyOutcome, FetchError> {
878 let contact = resolve_contact_email();
879 let crossref = crossref_source_from_env(&contact);
880 match crossref.fetch(ref_, profile, ctx).await {
881 Ok(res) => {
882 let metadata = res.metadata_json.unwrap_or(Value::Null);
883 let mut oa_url = extract_crossref_publisher_url(&metadata);
887 let mut oa_status = None;
891 let mut license = None;
892
893 if opts.include_oa_location {
894 let unpaywall = unpaywall_source_from_env(&contact);
895 match unpaywall.fetch(ref_, profile, ctx).await {
896 Ok(uw) => {
897 let uw_metadata = uw.metadata_json.unwrap_or(Value::Null);
898 oa_url = extract_unpaywall_oa_url(&uw_metadata);
899 oa_status = extract_unpaywall_oa_status(&uw_metadata);
900 license = if uw.license == "unknown" {
901 None
902 } else {
903 Some(uw.license)
904 };
905 }
906 Err(e) => {
907 tracing::debug!(
916 error = %e,
917 "metadata_only: OA location lookup failed; leaving oa_status null to say so"
918 );
919 }
920 }
921 }
922
923 Ok(MetadataOnlyOutcome {
930 metadata_quality: Vec::new(),
931 repaired_fields: std::collections::BTreeMap::new(),
932 source: crossref.name().to_string(),
933 resolver_profile: crossref.name().to_string(),
934 license,
935 oa_url,
936 oa_status,
937 metadata,
938 })
939 }
940 Err(crossref_err) => {
941 #[cfg(feature = "metadata")]
946 if let Some(outcome) = datacite_metadata_only(ref_, profile, ctx).await? {
947 return Ok(outcome);
948 }
949 let unpaywall = unpaywall_source_from_env(&contact);
952 match unpaywall.fetch(ref_, profile, ctx).await {
953 Ok(res) => {
954 let metadata = res.metadata_json.unwrap_or(Value::Null);
955 let oa_url = extract_unpaywall_oa_url(&metadata);
956 let oa_status = extract_unpaywall_oa_status(&metadata);
957 let license = if res.license == "unknown" {
958 None
959 } else {
960 Some(res.license)
961 };
962 Ok(MetadataOnlyOutcome {
963 metadata_quality: Vec::new(),
964 repaired_fields: std::collections::BTreeMap::new(),
965 source: unpaywall.name().to_string(),
966 resolver_profile: unpaywall.name().to_string(),
967 license,
968 oa_url,
969 oa_status,
970 metadata,
971 })
972 }
973 Err(_unpaywall_err) => {
974 Err(crossref_err)
977 }
978 }
979 }
980 }
981}
982
983#[cfg(feature = "metadata")]
987async fn datacite_metadata_only(
988 ref_: &Ref,
989 profile: &CapabilityProfile,
990 ctx: &FetchContext,
991) -> Result<Option<MetadataOnlyOutcome>, FetchError> {
992 let datacite = optional_base("DOIGET_DATACITE_BASE").map_or_else(
993 crate::sources::datacite::DataCiteSource::new,
994 crate::sources::datacite::DataCiteSource::with_base,
995 );
996 if !datacite.can_serve(profile, ref_) {
997 return Ok(None);
998 }
999 match datacite.fetch(ref_, profile, ctx).await {
1000 Ok(res) => Ok(Some(MetadataOnlyOutcome {
1001 metadata_quality: Vec::new(),
1002 repaired_fields: std::collections::BTreeMap::new(),
1003 source: datacite.name().to_string(),
1004 resolver_profile: datacite.name().to_string(),
1005 license: (res.license != "unknown").then_some(res.license),
1006 oa_url: None,
1007 oa_status: None,
1008 metadata: res.metadata_json.unwrap_or(Value::Null),
1009 })),
1010 Err(e @ FetchError::Log(_)) => Err(e),
1011 Err(e) => {
1012 tracing::debug!(error = %e, "metadata_only: DataCite has no record either");
1013 Ok(None)
1014 }
1015 }
1016}
1017
1018fn extract_crossref_publisher_url(msg: &Value) -> Option<String> {
1062 let arr = msg.get("link")?.as_array()?;
1063 let eligible = || {
1064 arr.iter().filter(|e| {
1065 e.get("intended-application")
1066 .and_then(Value::as_str)
1067 .is_some_and(|a| a.eq_ignore_ascii_case("unspecified"))
1068 })
1069 };
1070 let url_of = |e: &Value| {
1071 e.get("URL")
1072 .and_then(Value::as_str)
1073 .filter(|s| !s.is_empty())
1074 .map(str::to_string)
1075 };
1076 eligible()
1077 .find(|e| {
1078 e.get("content-type")
1079 .and_then(Value::as_str)
1080 .is_some_and(|c| c.eq_ignore_ascii_case("application/pdf"))
1081 })
1082 .and_then(url_of)
1083 .or_else(|| eligible().find_map(url_of))
1084}
1085
1086fn extract_unpaywall_oa_url(meta: &Value) -> Option<String> {
1090 let loc = meta.get("best_oa_location")?;
1091 loc.get("url_for_pdf")
1092 .and_then(Value::as_str)
1093 .or_else(|| loc.get("url").and_then(Value::as_str))
1094 .map(|s| s.to_string())
1095}
1096
1097fn extract_unpaywall_oa_status(meta: &Value) -> Option<String> {
1102 meta.get("oa_status")
1103 .and_then(Value::as_str)
1104 .filter(|s| !s.is_empty())
1105 .map(str::to_string)
1106}
1107
1108#[derive(Debug, Clone)]
1131#[non_exhaustive]
1132pub enum PdfLegStatus {
1133 Fetched,
1136 NoOaUrl,
1140 Blocked {
1146 code: crate::ErrorCode,
1149 message: String,
1151 denial: Option<crate::DenialContext>,
1154 suggested_arxiv_id: Option<String>,
1157 },
1158 PreprintFallback {
1164 arxiv_id: String,
1166 original_block: String,
1169 found_by: crate::preprint::FoundBy,
1172 },
1173 PreprintDoiFallback {
1177 preprint_doi: Doi,
1179 platform: Option<String>,
1181 original_block: String,
1183 found_by: crate::preprint::FoundBy,
1185 },
1186 TdmFetched {
1195 source: String,
1198 original_block: String,
1202 },
1203}
1204
1205#[derive(Debug, Clone)]
1214#[non_exhaustive]
1215pub struct FetchPaperOutcome {
1216 pub source: String,
1222 pub resolver_profile: String,
1232 pub license: String,
1235 pub oa_status: Option<String>,
1241 pub path: Utf8PathBuf,
1245 pub size_bytes: u64,
1248 pub schema_version: String,
1251 pub pdf_leg: PdfLegStatus,
1256 pub safekey: String,
1261 pub canonical_digest: String,
1268 pub title: String,
1272 pub authors: Vec<String>,
1274 pub year: Option<i32>,
1276 pub attempts: Vec<SourceAttempt>,
1287 pub metadata_quality: Vec<String>,
1291 pub repaired_fields: BTreeMap<String, String>,
1294}
1295
1296impl FetchPaperOutcome {
1297 #[must_use]
1305 pub fn reported_error_code(&self) -> Option<crate::ErrorCode> {
1306 match &self.pdf_leg {
1307 PdfLegStatus::Blocked { code, denial, .. } => {
1308 Some(match denial.as_ref().map(|d| d.reason) {
1309 Some(
1310 crate::DenialReason::RedirectNotInAllowlist
1311 | crate::DenialReason::InsecureScheme
1312 | crate::DenialReason::HostInBlockList,
1313 ) => crate::ErrorCode::CapabilityDenied,
1314 _ => *code,
1315 })
1316 }
1317 _ => None,
1318 }
1319 }
1320
1321 #[must_use]
1332 pub fn is_clean_success(&self) -> bool {
1333 !matches!(self.pdf_leg, PdfLegStatus::Blocked { .. })
1334 }
1335
1336 #[doc(hidden)]
1348 pub fn for_test_synthetic(
1349 safekey: impl Into<String>,
1350 source: impl Into<String>,
1351 pdf_leg: PdfLegStatus,
1352 ) -> Self {
1353 let safekey: String = safekey.into();
1354 let source: String = source.into();
1355 Self {
1356 source: source.clone(),
1357 resolver_profile: source.clone(),
1358 license: "unknown".to_string(),
1359 oa_status: None,
1360 path: Utf8PathBuf::from(format!("/tmp/{safekey}.pdf")),
1361 size_bytes: 0,
1362 schema_version: SCHEMA_VERSION.to_string(),
1363 pdf_leg,
1364 safekey: safekey.clone(),
1365 canonical_digest: "00".repeat(32),
1368 title: String::new(),
1369 authors: Vec::new(),
1370 year: None,
1371 attempts: Vec::new(),
1372 metadata_quality: Vec::new(),
1373 repaired_fields: BTreeMap::new(),
1374 }
1375 }
1376
1377 #[doc(hidden)]
1389 pub fn for_test_synthetic_with_attempts(
1390 safekey: impl Into<String>,
1391 source: impl Into<String>,
1392 pdf_leg: PdfLegStatus,
1393 attempts: Vec<SourceAttempt>,
1394 ) -> Self {
1395 Self {
1396 attempts,
1397 ..Self::for_test_synthetic(safekey, source, pdf_leg)
1398 }
1399 }
1400}
1401
1402pub async fn fetch_paper(
1437 ref_: &Ref,
1438 profile: &CapabilityProfile,
1439 ctx: &FetchContext,
1440 store: &dyn Store,
1441 store_root: &Utf8Path,
1442) -> Result<FetchPaperOutcome, FetchError> {
1443 fetch_paper_with(
1444 ref_,
1445 profile,
1446 ctx,
1447 store,
1448 store_root,
1449 FetchOptions::default(),
1450 )
1451 .await
1452}
1453
1454#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1457#[non_exhaustive]
1458pub struct FetchOptions {
1459 pub force: bool,
1463}
1464
1465impl FetchOptions {
1466 #[must_use]
1469 pub const fn with_force(mut self, force: bool) -> Self {
1470 self.force = force;
1471 self
1472 }
1473}
1474
1475pub async fn fetch_paper_with(
1489 ref_: &Ref,
1490 profile: &CapabilityProfile,
1491 ctx: &FetchContext,
1492 store: &dyn Store,
1493 store_root: &Utf8Path,
1494 opts: FetchOptions,
1495) -> Result<FetchPaperOutcome, FetchError> {
1496 let input = ref_.as_input_str();
1497 match ctx.log.repeat().check(input) {
1498 crate::repeat::Verdict::Proceed => {}
1499 verdict if opts.force => {
1500 let code = match &verdict {
1501 crate::repeat::Verdict::Replay { code, .. }
1502 | crate::repeat::Verdict::Wait { code, .. } => code.as_wire(),
1503 crate::repeat::Verdict::Proceed => "",
1504 };
1505 ctx.log.append(RowInput {
1506 event: LogEvent::RepeatForced,
1507 result: LogResult::Ok,
1508 capability: Capability::Oa,
1509 ref_: Some(input),
1510 source: None,
1511 error_code: Some(code).filter(|c| !c.is_empty()),
1512 size_bytes: None,
1513 license: None,
1514 store_path: None,
1515 canonical_digest: None,
1516 })?;
1517 }
1518 crate::repeat::Verdict::Replay { code, at } => {
1519 return Err(FetchError::Replayed {
1520 code,
1521 message: format!(
1522 "this session already asked about {input} at {at} and was told {} ({}); \
1523 nothing that decides it has changed since, so it was not asked again. \
1524 Pass force (MCP) or --refetch (CLI) to ask anyway.",
1525 code.as_wire(),
1526 code.disposition().as_wire(),
1527 ),
1528 retry_after_secs: None,
1529 });
1530 }
1531 crate::repeat::Verdict::Wait {
1532 code,
1533 at,
1534 remaining_secs,
1535 } => {
1536 return Err(FetchError::Replayed {
1537 code: crate::ErrorCode::RateLimited,
1538 message: format!(
1539 "this session asked about {input} at {at} and was told {} (retry_after); \
1540 retry in {remaining_secs}s, or pass force / --refetch to ask now.",
1541 code.as_wire(),
1542 ),
1543 retry_after_secs: Some(remaining_secs),
1544 });
1545 }
1546 }
1547 let safekey = ref_.safekey();
1548 match ref_ {
1549 Ref::Arxiv(id) => {
1550 fetch_paper_arxiv(id, ref_, profile, ctx, store, store_root, &safekey).await
1551 }
1552 Ref::Doi(doi) => {
1553 fetch_paper_doi(doi, ref_, profile, ctx, store, store_root, &safekey).await
1554 }
1555 }
1556}
1557
1558pub fn fetch_paper_plan(ref_: &Ref, store_root: &Utf8Path) -> FetchPlan {
1564 build_fetch_plan(ref_, store_root)
1565}
1566
1567pub fn try_fetch_paper_plan(ref_: &Ref, store_root: &Utf8Path) -> Result<FetchPlan, FetchError> {
1579 try_build_fetch_plan(ref_, store_root)
1580}
1581
1582async fn fetch_paper_arxiv(
1585 id: &ArxivId,
1586 ref_: &Ref,
1587 profile: &CapabilityProfile,
1588 ctx: &FetchContext,
1589 store: &dyn Store,
1590 store_root: &Utf8Path,
1591 safekey: &Safekey,
1592) -> Result<FetchPaperOutcome, FetchError> {
1593 let source = arxiv_source_from_env();
1594 if !source.can_serve(profile, ref_) {
1595 return Err(FetchError::NotEligible {
1596 source_key: source.name().to_string(),
1597 });
1598 }
1599
1600 let FetchResult {
1601 license,
1602 pdf_bytes,
1603 final_url,
1604 metadata_json,
1605 ..
1606 } = source.fetch(ref_, profile, ctx).await?;
1607 let pdf = pdf_bytes.ok_or_else(|| FetchError::SourceSchema {
1608 hint: "arxiv source returned no PDF bytes".to_string(),
1609 })?;
1610 let size_bytes = pdf.len() as u64;
1611
1612 let (title, authors, year, arxiv_categories) = match &metadata_json {
1619 Some(atom) => (
1620 extract_metadata_title(atom).unwrap_or_else(|| format!("arxiv:{}", id.as_str())),
1621 extract_metadata_authors(atom),
1622 atom.get("published")
1623 .and_then(Value::as_str)
1624 .and_then(parse_rfc3339_year),
1625 extract_arxiv_categories(atom),
1626 ),
1627 None => (
1628 format!("arxiv:{}", id.as_str()),
1629 Vec::new(),
1630 None,
1631 Vec::new(),
1632 ),
1633 };
1634
1635 let metadata = Metadata {
1636 schema_version: SCHEMA_VERSION.to_string(),
1637 title,
1638 authors,
1639 year,
1640 doi: None,
1641 arxiv_id: Some(id.clone()),
1642 arxiv_categories,
1643 abstract_: None,
1644 venue: None,
1645 volume: None,
1646 issue: None,
1647 pages: None,
1648 publisher: None,
1649 issn: None,
1650 isbn: None,
1651 type_: None,
1652 keywords: Vec::new(),
1653 url: final_url.as_ref().map(|u| u.to_string()),
1654 pdf_path: Some(format!("{}.pdf", safekey.as_str())),
1655 doiget: Some(DoigetExtension {
1656 fetched_at: Utc::now(),
1657 source: "arxiv".to_string(),
1658 license: license.clone(),
1659 oa_status: Some("green".to_string()),
1661 size_bytes,
1662 mcp_call_id: None,
1663 tags: Vec::new(),
1664 collections: Vec::new(),
1665 annotation: None,
1666 repaired_fields: Default::default(),
1667 short_venue: None,
1668 origin: None,
1669 }),
1670 other: BTreeMap::new(),
1671 };
1672
1673 let tmp = stage_pdf_to_tempfile(&pdf)?;
1674 let pdf_src = Utf8Path::from_path(tmp.path())
1675 .ok_or_else(|| FetchError::SourceSchema {
1676 hint: "staging tempfile path is not UTF-8".to_string(),
1677 })?
1678 .to_path_buf();
1679 write_metadata_and_pdf(store, safekey, &metadata, Some(&pdf_src), ctx)?;
1680 drop(tmp);
1681
1682 let path = store_root.join(format!("{}.pdf", safekey.as_str()));
1683 let canonical_digest =
1684 crate::CanonicalRef::new(crate::SourceType::Arxiv, id.as_str(), "arxiv", None).digest_hex();
1685 Ok(FetchPaperOutcome {
1686 source: "arxiv".to_string(),
1687 resolver_profile: "arxiv".to_string(),
1688 license,
1689 oa_status: Some("green".to_string()),
1690 path,
1691 size_bytes,
1692 schema_version: SCHEMA_VERSION.to_string(),
1693 pdf_leg: PdfLegStatus::Fetched,
1696 safekey: safekey.as_str().to_string(),
1697 canonical_digest,
1698 title: metadata.title.clone(),
1699 authors: metadata.authors.clone(),
1700 year: metadata.year,
1701 attempts: Vec::new(),
1703 metadata_quality: crate::metadata_quality::QualityReport {
1706 remaining: crate::metadata_quality::replacement_char_fields(&metadata),
1707 ..Default::default()
1708 }
1709 .flags(),
1710 repaired_fields: BTreeMap::new(),
1711 })
1712}
1713
1714async fn fetch_paper_doi(
1719 doi: &Doi,
1720 ref_: &Ref,
1721 profile: &CapabilityProfile,
1722 ctx: &FetchContext,
1723 store: &dyn Store,
1724 store_root: &Utf8Path,
1725 safekey: &Safekey,
1726) -> Result<FetchPaperOutcome, FetchError> {
1727 let addresses = resolve_contact_emails();
1728 let contact = addresses.crossref;
1729 let unpaywall_contact = addresses.unpaywall;
1730 let crossref = crossref_source_from_env(&contact);
1731 let (cross, crossref_err) = match crossref.fetch(ref_, profile, ctx).await {
1736 Ok(r) => (Some(r), None),
1737 Err(e) => {
1738 tracing::warn!(
1739 error = %e,
1740 "crossref fetch failed; continuing with unpaywall-only metadata + OA leg"
1741 );
1742 (None, Some(e))
1743 }
1744 };
1745 let crossref_meta = cross
1746 .as_ref()
1747 .and_then(|c| c.metadata_json.clone())
1748 .unwrap_or(Value::Null);
1749 #[allow(unused_mut)]
1752 let mut extracted = extract_crossref_fields(&crossref_meta);
1753
1754 #[allow(unused_mut)]
1767 let mut attempts: Vec<SourceAttempt> = Vec::new();
1768
1769 #[cfg(any(
1780 feature = "tdm-elsevier",
1781 feature = "tdm-aps",
1782 feature = "tdm-springer",
1783 feature = "tdm-ieee"
1784 ))]
1785 let tdm_meta = resolve_tdm_chain(ref_, profile, ctx, cross.is_some(), &mut attempts).await;
1786 #[cfg(not(any(
1792 feature = "tdm-elsevier",
1793 feature = "tdm-aps",
1794 feature = "tdm-springer",
1795 feature = "tdm-ieee"
1796 )))]
1797 let tdm_meta: Option<Value> = None;
1798
1799 #[cfg(feature = "metadata")]
1804 let optional_resolved = resolve_optional_chain(
1805 ref_,
1806 profile,
1807 ctx,
1808 cross.is_some() || tdm_meta.is_some(),
1809 &mut extracted,
1810 &mut attempts,
1811 )
1812 .await;
1813 #[cfg(feature = "metadata")]
1814 let optional_meta = optional_resolved
1815 .as_ref()
1816 .map(|(_, m)| m.clone())
1817 .or(tdm_meta);
1818 #[cfg(not(feature = "metadata"))]
1819 let optional_meta: Option<Value> = tdm_meta;
1820 let _ = &optional_meta;
1821
1822 let unpaywall = unpaywall_source_from_env(&unpaywall_contact);
1826 let upw_result = unpaywall.fetch(ref_, profile, ctx).await;
1827 let (mut license, source_label, oa_chain, oa_status) = match upw_result {
1828 Ok(r) => {
1829 let chain = extract_oa_url_chain(r.metadata_json.as_ref());
1830 let oa_status = r
1835 .metadata_json
1836 .as_ref()
1837 .and_then(extract_unpaywall_oa_status);
1838 let label = if r.license != "unknown" {
1839 "unpaywall".to_string()
1840 } else {
1841 "crossref".to_string()
1842 };
1843 (r.license, label, chain, oa_status)
1844 }
1845 Err(e) => {
1846 tracing::warn!(
1860 error = %e,
1861 doi = %doi.as_str(),
1862 "unpaywall fetch failed; OA chain will be empty (downstream PdfLegStatus::NoOaUrl \
1863 is conservative — Unpaywall was unreachable, not authoritatively oa-free)"
1864 );
1865 (
1866 "unknown".to_string(),
1867 "crossref".to_string(),
1868 Vec::new(),
1869 None,
1870 )
1871 }
1872 };
1873
1874 let (pdf_leg, pdf_bytes) = if oa_chain.is_empty() {
1888 (PdfLegStatus::NoOaUrl, None)
1889 } else {
1890 let mut succeeded: Option<Vec<u8>> = None;
1891 let mut last_err: Option<HttpError> = None;
1892 let total = oa_chain.len();
1893 for (idx, candidate) in oa_chain.iter().enumerate() {
1894 let attempt = idx + 1;
1895 tracing::debug!(
1896 attempt,
1897 total,
1898 url = %candidate,
1899 "trying OA PDF candidate (ADR-0029 chain)"
1900 );
1901 match try_fetch_oa_pdf(doi, candidate, ctx).await {
1902 Ok((bytes, _final_url)) => {
1903 if attempt > 1 {
1904 tracing::info!(
1905 attempt,
1906 total,
1907 url = %candidate,
1908 "OA PDF chain succeeded on fallback candidate (ADR-0029)"
1909 );
1910 }
1911 succeeded = Some(bytes);
1912 break;
1913 }
1914 Err(e) => {
1915 tracing::warn!(
1916 attempt,
1917 total,
1918 url = %candidate,
1919 error = %e,
1920 "OA PDF candidate failed; advancing to next (ADR-0029 chain)"
1921 );
1922 last_err = Some(e);
1923 }
1924 }
1925 }
1926 match (succeeded, last_err) {
1927 (Some(bytes), _) => (PdfLegStatus::Fetched, Some(bytes)),
1928 (None, Some(e)) => {
1929 let fe = FetchError::Http(e);
1930 let denial: Option<crate::DenialContext> = (&fe).into();
1931 let message = fe.to_string();
1932 let code: crate::ErrorCode = fe.into();
1933 let suggested_arxiv_id = oa_chain.iter().find_map(extract_arxiv_id_from_url);
1934 (
1935 PdfLegStatus::Blocked {
1936 code,
1937 message,
1938 denial,
1939 suggested_arxiv_id,
1940 },
1941 None,
1942 )
1943 }
1944 (None, None) => {
1954 tracing::error!(
1955 total = oa_chain.len(),
1956 "OA PDF chain walker exhausted without recording success or error \
1957 (defensive fallback — should be unreachable)"
1958 );
1959 (
1960 PdfLegStatus::Blocked {
1961 code: crate::ErrorCode::InternalError,
1962 message:
1963 "OA PDF chain walker exhausted without recording success or error \
1964 (orchestrator bug — please report)"
1965 .to_string(),
1966 denial: None,
1967 suggested_arxiv_id: None,
1968 },
1969 None,
1970 )
1971 }
1972 }
1973 };
1974
1975 let discovered = match &pdf_leg {
1982 PdfLegStatus::NoOaUrl
1983 | PdfLegStatus::Blocked {
1984 suggested_arxiv_id: None,
1985 ..
1986 } => crate::preprint::find(doi, &crossref_meta, &profile.metadata, ctx).await?,
1987 _ => None,
1988 };
1989 let (pdf_leg, pdf_bytes, arxiv_id_for_metadata, fallback_license) =
1990 try_arxiv_preprint_fallback(doi, pdf_leg, pdf_bytes, discovered, profile, ctx).await;
1991
1992 #[cfg(feature = "metadata")]
1995 let (pdf_leg, pdf_bytes) = try_optional_source_oa_fallback(
1996 doi,
1997 pdf_leg,
1998 pdf_bytes,
1999 profile,
2000 ctx,
2001 &mut attempts,
2002 optional_resolved.as_ref().map(|(n, m)| (*n, m)),
2003 )
2004 .await;
2005
2006 #[cfg(any(
2014 feature = "tdm-elsevier",
2015 feature = "tdm-aps",
2016 feature = "tdm-springer",
2017 feature = "tdm-ieee"
2018 ))]
2019 let (pdf_leg, pdf_bytes) =
2020 try_tdm_content_fallback(doi, pdf_leg, pdf_bytes, profile, ctx, &mut attempts).await;
2021
2022 let (pdf_leg, pdf_bytes, preprint_license) = try_preprint_doi_fallback(
2025 doi,
2026 &crossref_meta,
2027 pdf_leg,
2028 pdf_bytes,
2029 &unpaywall_contact,
2030 profile,
2031 ctx,
2032 )
2033 .await?;
2034 let fallback_license = fallback_license.or(preprint_license);
2035
2036 if let Some(fl) = fallback_license {
2037 license = fl;
2038 }
2039
2040 #[cfg(any(
2052 feature = "tdm-elsevier",
2053 feature = "tdm-aps",
2054 feature = "tdm-springer",
2055 feature = "tdm-ieee"
2056 ))]
2057 if matches!(pdf_leg, PdfLegStatus::TdmFetched { .. }) {
2058 license = "unknown".to_string();
2059 }
2060
2061 if let Some(e) = crossref_err {
2066 if pdf_bytes.is_none() && optional_meta.is_none() {
2073 if !attempts.is_empty() {
2079 let trace = render_attempts(&attempts);
2080 let lead = if nothing_was_consulted(&attempts) {
2081 "no optional source was consulted for this DOI"
2082 } else {
2083 "the optional sources were consulted and did not resolve it"
2084 };
2085 return Err(FetchError::NotFound {
2086 hint: format!(
2087 "{e}
2088 = note: {lead}:
2089{trace}"
2090 ),
2091 });
2092 }
2093 return Err(e);
2094 }
2095 }
2096
2097 let (final_source_label, size_bytes, pdf_path_relative, pdf_staged) = match &pdf_bytes {
2098 Some(bytes) => {
2099 let staged = stage_pdf_to_tempfile(bytes)?;
2100 let label = match &pdf_leg {
2105 PdfLegStatus::PreprintFallback { .. } => "arxiv".to_string(),
2106 PdfLegStatus::TdmFetched { source, .. } => source.clone(),
2107 _ => "oa-publisher".to_string(),
2108 };
2109 (
2110 label,
2111 bytes.len() as u64,
2112 Some(format!("{}.pdf", safekey.as_str())),
2113 Some(staged),
2114 )
2115 }
2116 None => (source_label, 0u64, None, None),
2117 };
2118
2119 let mut metadata = Metadata {
2120 schema_version: SCHEMA_VERSION.to_string(),
2121 title: extracted
2124 .title
2125 .map(|t| crate::markup::plain_title(&t))
2126 .unwrap_or_else(|| doi.as_str().to_string()),
2127 authors: extracted.authors,
2128 year: extracted.year,
2129 doi: Some(doi.clone()),
2130 arxiv_id: arxiv_id_for_metadata,
2131 arxiv_categories: Vec::new(),
2133 abstract_: None,
2134 venue: extracted.venue.map(|v| crate::markup::plain_title(&v)),
2135 volume: extracted.volume,
2136 issue: extracted.issue,
2137 pages: extracted.pages,
2138 publisher: None,
2139 issn: None,
2140 isbn: None,
2141 type_: extracted.type_,
2142 keywords: Vec::new(),
2143 url: cross
2144 .as_ref()
2145 .and_then(|c| c.final_url.as_ref())
2146 .map(|u| u.to_string()),
2147 pdf_path: pdf_path_relative,
2148 doiget: Some(DoigetExtension {
2149 fetched_at: Utc::now(),
2150 source: final_source_label.clone(),
2151 license: license.clone(),
2152 oa_status: oa_status.clone(),
2153 size_bytes,
2154 mcp_call_id: None,
2155 tags: Vec::new(),
2156 collections: Vec::new(),
2157 annotation: None,
2158 repaired_fields: Default::default(),
2159 short_venue: extracted.short_venue.clone(),
2160 origin: None,
2161 }),
2162 other: BTreeMap::new(),
2163 };
2164 if let PdfLegStatus::PreprintDoiFallback { preprint_doi, .. } = &pdf_leg {
2166 metadata.other.insert(
2167 "preprint_doi".into(),
2168 toml::Value::String(preprint_doi.as_str().to_string()),
2169 );
2170 }
2171 let quality = crate::metadata_quality::repair(&mut metadata, profile, ctx).await;
2174
2175 let pdf_src_path = pdf_staged
2176 .as_ref()
2177 .and_then(|tmp| Utf8Path::from_path(tmp.path()).map(|p| p.to_path_buf()));
2178 write_metadata_and_pdf(store, safekey, &metadata, pdf_src_path.as_deref(), ctx)?;
2179 drop(pdf_staged);
2180
2181 let path = if pdf_bytes.is_some() {
2182 store_root.join(format!("{}.pdf", safekey.as_str()))
2183 } else {
2184 store_root
2185 .join(".metadata")
2186 .join(format!("{}.toml", safekey.as_str()))
2187 };
2188 let canonical_digest = crate::CanonicalRef::new(
2189 crate::SourceType::Doi,
2190 doi.as_str(),
2191 &final_source_label,
2192 None,
2193 )
2194 .digest_hex();
2195 Ok(FetchPaperOutcome {
2196 source: final_source_label.clone(),
2197 resolver_profile: final_source_label,
2198 license,
2199 oa_status,
2200 path,
2201 size_bytes,
2202 schema_version: SCHEMA_VERSION.to_string(),
2203 pdf_leg,
2204 safekey: safekey.as_str().to_string(),
2205 canonical_digest,
2206 title: metadata.title.clone(),
2207 authors: metadata.authors.clone(),
2208 year: metadata.year,
2209 attempts,
2210 metadata_quality: quality.flags(),
2211 repaired_fields: quality.repaired,
2212 })
2213}
2214
2215#[cfg(feature = "metadata")]
2231fn describe_optional_source_locations(source: &str, meta: &Value) -> Option<(usize, String)> {
2232 match source {
2233 "openalex" => crate::sources::openalex::describe_locations(meta),
2234 _ => None,
2235 }
2236}
2237
2238#[cfg(feature = "metadata")]
2239fn optional_source_oa_url<'a>(source: &str, meta: &'a Value) -> Option<&'a str> {
2240 match source {
2241 "openalex" => crate::sources::openalex::open_access_pdf_url(meta),
2245 "core" => crate::sources::core_oa::open_access_pdf_url(meta),
2246 "hal" => crate::sources::hal::open_access_pdf_url(meta),
2247 "europe-pmc" => crate::sources::europepmc::open_access_pdf_url(meta),
2248 _ => None,
2249 }
2250}
2251
2252#[cfg(feature = "metadata")]
2273async fn try_optional_source_oa_fallback(
2274 doi: &Doi,
2275 pdf_leg: PdfLegStatus,
2276 pdf_bytes: Option<Vec<u8>>,
2277 profile: &CapabilityProfile,
2278 ctx: &FetchContext,
2279 attempts: &mut Vec<SourceAttempt>,
2280 already_resolved: Option<(&'static str, &Value)>,
2281) -> (PdfLegStatus, Option<Vec<u8>>) {
2282 if pdf_bytes.is_some()
2288 || !matches!(
2289 pdf_leg,
2290 PdfLegStatus::Blocked { .. } | PdfLegStatus::NoOaUrl
2291 )
2292 {
2293 return (pdf_leg, pdf_bytes);
2294 }
2295
2296 let (name, meta) = match already_resolved {
2320 Some((n, m)) => (n, m.clone()),
2321 None => {
2322 let ref_ = Ref::Doi(doi.clone());
2323 let mut discard = CrossrefFields::default();
2324 let mut fresh: Vec<SourceAttempt> = Vec::new();
2325 let r =
2326 resolve_optional_chain(&ref_, profile, ctx, false, &mut discard, &mut fresh).await;
2327 if !fresh.is_empty() {
2328 attempts.retain(|a| !fresh.iter().any(|f| f.source == a.source));
2329 attempts.extend(fresh);
2330 }
2331 match r {
2332 Some((n, m)) => (n, m),
2333 None => return (pdf_leg, pdf_bytes),
2334 }
2335 }
2336 };
2337 let Some(raw) = optional_source_oa_url(name, &meta) else {
2338 if let Some((oa, detail)) = describe_optional_source_locations(name, &meta) {
2352 if oa == 0 {
2353 if let Some(row) = attempts.iter_mut().find(|a| a.source == name) {
2354 row.outcome = AttemptOutcome::NotOpenAccess { detail };
2355 }
2356 }
2357 }
2358 tracing::debug!(
2359 source = name,
2360 doi = %doi.as_str(),
2361 "optional source resolved but reported no document URL"
2362 );
2363 return (pdf_leg, pdf_bytes);
2364 };
2365 let Ok(url) = url::Url::parse(raw) else {
2366 tracing::warn!(
2367 source = name,
2368 url = raw,
2369 "optional source reported an unparsable document URL; keeping Blocked"
2370 );
2371 return (pdf_leg, pdf_bytes);
2372 };
2373
2374 tracing::info!(
2375 source = name,
2376 doi = %doi.as_str(),
2377 url = %url,
2378 "OA chain exhausted; trying a copy reported by an optional source (#445)"
2379 );
2380 match try_fetch_oa_pdf(doi, &url, ctx).await {
2381 Ok((bytes, _final_url)) => (PdfLegStatus::Fetched, Some(bytes)),
2382 Err(e) => {
2383 tracing::warn!(
2386 source = name,
2387 error = %e,
2388 "optional-source copy also failed; keeping the original block"
2389 );
2390 (pdf_leg, pdf_bytes)
2391 }
2392 }
2393}
2394
2395#[cfg(any(
2412 feature = "tdm-elsevier",
2413 feature = "tdm-aps",
2414 feature = "tdm-springer",
2415 feature = "tdm-ieee"
2416))]
2417#[allow(clippy::vec_init_then_push)]
2418async fn try_tdm_content_fallback(
2419 doi: &Doi,
2420 pdf_leg: PdfLegStatus,
2421 pdf_bytes: Option<Vec<u8>>,
2422 profile: &CapabilityProfile,
2423 ctx: &FetchContext,
2424 attempts: &mut Vec<SourceAttempt>,
2425) -> (PdfLegStatus, Option<Vec<u8>>) {
2426 struct ContentEntry<'a> {
2427 name: &'static str,
2428 enable_hint: &'static [&'static str],
2430 prefixes: &'static [&'static str],
2432 publisher: &'static str,
2434 src: &'a dyn crate::source::Source,
2435 }
2436
2437 if pdf_bytes.is_some() {
2438 return (pdf_leg, pdf_bytes);
2439 }
2440 let PdfLegStatus::Blocked {
2441 message: ref blocked_message,
2442 ..
2443 } = pdf_leg
2444 else {
2445 return (pdf_leg, pdf_bytes);
2446 };
2447 let original_block = blocked_message.clone();
2448
2449 let ref_ = Ref::Doi(doi.clone());
2450
2451 #[cfg(feature = "tdm-aps")]
2452 let aps = optional_base("DOIGET_APS_BASE").map_or_else(
2453 crate::sources::tdm_aps::TdmApsSource::new,
2454 crate::sources::tdm_aps::TdmApsSource::with_base,
2455 );
2456 #[cfg(feature = "tdm-elsevier")]
2457 let elsevier = optional_base("DOIGET_ELSEVIER_BASE").map_or_else(
2458 crate::sources::tdm_elsevier::TdmElsevierSource::new,
2459 crate::sources::tdm_elsevier::TdmElsevierSource::with_base,
2460 );
2461 #[cfg(feature = "tdm-springer")]
2462 let springer = optional_base("DOIGET_SPRINGER_BASE").map_or_else(
2463 crate::sources::tdm_springer::TdmSpringerSource::new,
2464 crate::sources::tdm_springer::TdmSpringerSource::with_base,
2465 );
2466 #[cfg(feature = "tdm-ieee")]
2467 let ieee = optional_base("DOIGET_IEEE_BASE").map_or_else(
2468 crate::sources::tdm_ieee::TdmIeeeSource::new,
2469 crate::sources::tdm_ieee::TdmIeeeSource::with_base,
2470 );
2471
2472 #[allow(unused_mut)]
2479 let mut chain: Vec<ContentEntry<'_>> = Vec::new();
2480 #[cfg(feature = "tdm-aps")]
2481 chain.push(ContentEntry {
2482 name: "tdm-aps",
2483 enable_hint: &["DOIGET_KEY_APS", "DOIGET_AGREE_TDM_APS"],
2484 prefixes: crate::sources::tdm_aps::PUBLISHER_PREFIXES,
2485 publisher: "American Physical Society (APS)",
2486 src: &aps,
2487 });
2488 #[cfg(feature = "tdm-elsevier")]
2489 chain.push(ContentEntry {
2490 name: "tdm-elsevier",
2491 enable_hint: &["DOIGET_KEY_ELSEVIER", "DOIGET_AGREE_TDM_ELSEVIER"],
2492 prefixes: crate::sources::tdm_elsevier::PUBLISHER_PREFIXES,
2493 publisher: "Elsevier BV",
2494 src: &elsevier,
2495 });
2496 #[cfg(feature = "tdm-springer")]
2497 chain.push(ContentEntry {
2498 name: "tdm-springer",
2499 enable_hint: &["DOIGET_KEY_SPRINGER", "DOIGET_AGREE_TDM_SPRINGER"],
2500 prefixes: crate::sources::tdm_springer::PUBLISHER_PREFIXES,
2501 publisher: "Springer Nature",
2502 src: &springer,
2503 });
2504 #[cfg(feature = "tdm-ieee")]
2505 chain.push(ContentEntry {
2506 name: "tdm-ieee",
2507 enable_hint: &["DOIGET_KEY_IEEE", "DOIGET_AGREE_TDM_IEEE"],
2508 prefixes: crate::sources::tdm_ieee::PUBLISHER_PREFIXES,
2509 publisher: "IEEE",
2510 src: &ieee,
2511 });
2512
2513 fn record(attempts: &mut Vec<SourceAttempt>, name: &'static str, outcome: AttemptOutcome) {
2517 attempts.retain(|a| a.source != name);
2518 attempts.push(SourceAttempt::new(name, outcome));
2519 }
2520
2521 for e in chain {
2522 debug_assert_eq!(e.name, e.src.name(), "chain name must match Source::name");
2523
2524 if !e.prefixes.contains(&doi.prefix()) {
2529 record(
2530 attempts,
2531 e.name,
2532 AttemptOutcome::WrongPublisher {
2533 detail: format!("DOI prefix {} is not {}", doi.prefix(), e.publisher),
2534 },
2535 );
2536 continue;
2537 }
2538 if !e.src.can_serve(profile, &ref_) {
2539 record(
2540 attempts,
2541 e.name,
2542 AttemptOutcome::Disabled { env: e.enable_hint },
2543 );
2544 continue;
2545 }
2546
2547 match e.src.fetch_content(&ref_, profile, ctx).await {
2548 Ok(None) => {}
2552 Ok(Some(bytes)) => {
2553 tracing::info!(
2554 source = e.name,
2555 doi = %doi.as_str(),
2556 size = bytes.len(),
2557 "OA routes exhausted; the publisher served its own copy under the user's TDM agreement (#458)"
2558 );
2559 record(attempts, e.name, AttemptOutcome::Resolved);
2560 return (
2561 PdfLegStatus::TdmFetched {
2562 source: e.name.to_string(),
2563 original_block,
2564 },
2565 Some(bytes.to_vec()),
2566 );
2567 }
2568 Err(err) => {
2569 tracing::warn!(
2570 source = e.name,
2571 error = %err,
2572 "TDM content leg failed; keeping the original block"
2573 );
2574 record(attempts, e.name, classify_attempt(&err));
2575 }
2576 }
2577 }
2578
2579 (pdf_leg, pdf_bytes)
2580}
2581
2582async fn try_arxiv_preprint_fallback(
2593 doi: &Doi,
2594 pdf_leg: PdfLegStatus,
2595 oa_pdf_bytes: Option<Vec<u8>>,
2596 discovered: Option<crate::preprint::Found>,
2597 profile: &CapabilityProfile,
2598 ctx: &FetchContext,
2599) -> (
2600 PdfLegStatus,
2601 Option<Vec<u8>>,
2602 Option<ArxivId>,
2603 Option<String>,
2604) {
2605 let (arxiv_id_str, original_block, found_by) = match (&pdf_leg, &discovered) {
2606 (
2607 PdfLegStatus::Blocked {
2608 suggested_arxiv_id: Some(s),
2609 message,
2610 ..
2611 },
2612 _,
2613 ) => (
2614 s.clone(),
2615 message.clone(),
2616 crate::preprint::FoundBy::Unpaywall,
2617 ),
2618 (PdfLegStatus::Blocked { message, .. }, Some(f)) => {
2619 (f.arxiv_id.as_str().to_string(), message.clone(), f.found_by)
2620 }
2621 (PdfLegStatus::NoOaUrl, Some(f)) => (
2622 f.arxiv_id.as_str().to_string(),
2623 "no open copy known to Unpaywall".to_string(),
2624 f.found_by,
2625 ),
2626 _ => return (pdf_leg, oa_pdf_bytes, None, None),
2627 };
2628
2629 let arxiv_id = match ArxivId::parse(&arxiv_id_str) {
2630 Ok(id) => id,
2631 Err(e) => {
2632 tracing::warn!(
2633 error = %e,
2634 arxiv_id = %arxiv_id_str,
2635 doi = %doi.as_str(),
2636 "preprint fallback: could not parse suggested_arxiv_id; keeping Blocked"
2637 );
2638 return (pdf_leg, oa_pdf_bytes, None, None);
2639 }
2640 };
2641
2642 tracing::info!(
2643 doi = %doi.as_str(),
2644 arxiv_id = %arxiv_id.as_str(),
2645 "OA PDF blocked; attempting arXiv preprint fallback (issue #325)"
2646 );
2647
2648 let arxiv_ref = Ref::Arxiv(arxiv_id.clone());
2649 let arxiv_source = arxiv_source_from_env();
2650 match arxiv_source.fetch(&arxiv_ref, profile, ctx).await {
2651 Ok(result) => match result.pdf_bytes {
2652 Some(bytes) => {
2653 tracing::info!(
2654 doi = %doi.as_str(),
2655 arxiv_id = %arxiv_id.as_str(),
2656 size = bytes.len(),
2657 "arXiv preprint fallback succeeded; storing under DOI safekey (issue #325)"
2658 );
2659 let license = result.license;
2660 (
2661 PdfLegStatus::PreprintFallback {
2662 arxiv_id: arxiv_id.as_str().to_string(),
2663 original_block,
2664 found_by,
2665 },
2666 Some(bytes.to_vec()),
2667 Some(arxiv_id),
2668 Some(license),
2669 )
2670 }
2671 None => {
2672 tracing::warn!(
2673 doi = %doi.as_str(),
2674 arxiv_id = %arxiv_id.as_str(),
2675 "preprint fallback: arXiv source returned no PDF bytes; keeping Blocked"
2676 );
2677 (pdf_leg, oa_pdf_bytes, None, None)
2678 }
2679 },
2680 Err(e) => {
2681 tracing::warn!(
2682 error = %e,
2683 doi = %doi.as_str(),
2684 arxiv_id = %arxiv_id.as_str(),
2685 "preprint fallback: arXiv fetch also failed; keeping Blocked"
2686 );
2687 (pdf_leg, oa_pdf_bytes, None, None)
2688 }
2689 }
2690}
2691
2692async fn try_preprint_doi_fallback(
2699 doi: &Doi,
2700 crossref_meta: &Value,
2701 pdf_leg: PdfLegStatus,
2702 pdf_bytes: Option<Vec<u8>>,
2703 unpaywall_contact: &str,
2704 profile: &CapabilityProfile,
2705 ctx: &FetchContext,
2706) -> Result<(PdfLegStatus, Option<Vec<u8>>, Option<String>), FetchError> {
2707 let original_block = match &pdf_leg {
2708 PdfLegStatus::NoOaUrl => "no open copy known to Unpaywall".to_string(),
2709 PdfLegStatus::Blocked { message, .. } => message.clone(),
2710 _ => return Ok((pdf_leg, pdf_bytes, None)),
2711 };
2712 let Some(found) =
2713 crate::preprint::find_preprint_doi(doi, crossref_meta, profile.metadata.biorxiv, ctx)
2714 .await?
2715 else {
2716 return Ok((pdf_leg, pdf_bytes, None));
2717 };
2718 let preprint_ref = Ref::Doi(found.doi.clone());
2719 let unpaywall = unpaywall_source_from_env(unpaywall_contact);
2720 let located = match unpaywall.fetch(&preprint_ref, profile, ctx).await {
2721 Ok(r) => r,
2722 Err(FetchError::Log(e)) => return Err(FetchError::Log(e)),
2723 Err(e) => {
2724 tracing::info!(error = %e, preprint = %found.doi.as_str(), "preprint DOI: Unpaywall did not answer");
2725 return Ok((pdf_leg, pdf_bytes, None));
2726 }
2727 };
2728 let license = located.license.clone();
2729 for candidate in extract_oa_url_chain(located.metadata_json.as_ref()) {
2730 match try_fetch_oa_pdf(&found.doi, &candidate, ctx).await {
2731 Ok((bytes, _)) => {
2732 return Ok((
2733 PdfLegStatus::PreprintDoiFallback {
2734 preprint_doi: found.doi.clone(),
2735 platform: found.platform,
2736 original_block,
2737 found_by: found.found_by,
2738 },
2739 Some(bytes),
2740 Some(license),
2741 ));
2742 }
2743 Err(e) => {
2744 tracing::info!(error = %e, url = %candidate, "preprint DOI: OA candidate failed")
2745 }
2746 }
2747 }
2748 Ok((pdf_leg, pdf_bytes, None))
2749}
2750
2751pub(crate) fn stage_pdf_to_tempfile(bytes: &[u8]) -> Result<tempfile::NamedTempFile, FetchError> {
2754 let tmp = tempfile::NamedTempFile::new().map_err(|e| FetchError::SourceSchema {
2755 hint: format!("creating PDF staging tempfile: {e}"),
2756 })?;
2757 std::fs::write(tmp.path(), bytes).map_err(|e| FetchError::SourceSchema {
2758 hint: format!("staging PDF bytes: {e}"),
2759 })?;
2760 Ok(tmp)
2761}
2762
2763pub(crate) fn write_metadata_and_pdf(
2766 store: &dyn Store,
2767 safekey: &Safekey,
2768 metadata: &Metadata,
2769 pdf_src: Option<&Utf8Path>,
2770 ctx: &FetchContext,
2771) -> Result<(), FetchError> {
2772 let store_path_relative = if pdf_src.is_some() {
2773 format!("{}.pdf", safekey.as_str())
2774 } else {
2775 format!(".metadata/{}.toml", safekey.as_str())
2776 };
2777 let size_bytes = metadata.doiget.as_ref().map(|d| d.size_bytes).unwrap_or(0);
2778 let license = metadata.doiget.as_ref().map(|d| d.license.as_str());
2779 let source_name = metadata.doiget.as_ref().map(|d| d.source.as_str());
2780
2781 let canonical_digest: Option<String> = match (metadata.doi.as_ref(), metadata.arxiv_id.as_ref())
2786 {
2787 (Some(d), _) => source_name.map(|s| {
2788 crate::CanonicalRef::new(crate::SourceType::Doi, d.as_str(), s, None).digest_hex()
2789 }),
2790 (None, Some(a)) => source_name.map(|s| {
2791 crate::CanonicalRef::new(crate::SourceType::Arxiv, a.as_str(), s, None).digest_hex()
2792 }),
2793 (None, None) => None,
2794 };
2795
2796 match crate::store::blocking_section(|| store.write(safekey, metadata, pdf_src)) {
2797 Ok(()) => {
2798 ctx.log.append(RowInput {
2799 event: LogEvent::StoreWrite,
2800 result: LogResult::Ok,
2801 capability: if metadata.doiget.as_ref().and_then(|d| d.origin.as_deref())
2803 == Some(crate::store::ORIGIN_USER_SUPPLIED)
2804 {
2805 Capability::UserSupplied
2806 } else {
2807 Capability::Oa
2808 },
2809 ref_: metadata
2810 .doi
2811 .as_ref()
2812 .map(|d| d.as_str())
2813 .or_else(|| metadata.arxiv_id.as_ref().map(|a| a.as_str())),
2814 source: source_name,
2815 error_code: None,
2816 size_bytes: Some(size_bytes),
2817 license,
2818 store_path: Some(&store_path_relative),
2819 canonical_digest: canonical_digest.as_deref(),
2820 })?;
2821 Ok(())
2822 }
2823 Err(e) => {
2824 if let Err(log_err) = ctx.log.append(RowInput {
2836 event: LogEvent::StoreWrite,
2837 result: LogResult::Err,
2838 capability: Capability::Oa,
2839 ref_: metadata
2840 .doi
2841 .as_ref()
2842 .map(|d| d.as_str())
2843 .or_else(|| metadata.arxiv_id.as_ref().map(|a| a.as_str())),
2844 source: source_name,
2845 error_code: Some("STORE_ERROR"),
2846 size_bytes: None,
2847 license: None,
2848 store_path: Some(&store_path_relative),
2849 canonical_digest: canonical_digest.as_deref(),
2850 }) {
2851 tracing::error!(
2852 store_err = %e,
2853 log_err = %log_err,
2854 "BOTH store.write AND provenance log append failed; \
2855 audit trail is broken for this attempt"
2856 );
2857 }
2858 Err(FetchError::SourceSchema {
2859 hint: format!("store write failed: {e}"),
2860 })
2861 }
2862 }
2863}
2864
2865async fn try_fetch_oa_pdf(
2867 doi: &Doi,
2868 url: &url::Url,
2869 ctx: &FetchContext,
2870) -> Result<(Vec<u8>, url::Url), HttpError> {
2871 const SOURCE: &str = "oa-publisher";
2872 let _permit = ctx.rate_limiter.acquire(SOURCE).await;
2873 let canonical =
2879 crate::CanonicalRef::new(crate::SourceType::Doi, doi.as_str(), SOURCE, None).digest_hex();
2880
2881 if let Some(allowlist) = ctx.http.source_allowlist(SOURCE) {
2904 let host = url
2908 .host_str()
2909 .map(|h| h.to_ascii_lowercase())
2910 .unwrap_or_default();
2911 if !allowlist.permits(&host) {
2915 let e = HttpError::RedirectDenied {
2916 source_key: SOURCE.to_string(),
2917 host: host.clone(),
2918 expected_hosts: allowlist.redirect_hosts.clone(),
2919 };
2920 tracing::info!(
2921 oa_url = %url,
2922 denied_host = %host,
2923 "OA URL host outside oa-publisher allowlist (pre-fetch check, \
2924 docs/REDIRECT_ALLOWLIST.md §1 / issue #145)"
2925 );
2926 let _ = ctx.log.append(RowInput {
2932 event: LogEvent::Fetch,
2933 result: LogResult::Err,
2934 capability: Capability::Oa,
2935 ref_: Some(doi.as_str()),
2936 source: Some(SOURCE),
2937 error_code: Some(crate::ErrorCode::NetworkError.as_wire()),
2938 size_bytes: None,
2939 license: None,
2940 store_path: None,
2941 canonical_digest: Some(&canonical),
2942 });
2943 return Err(e);
2944 }
2945 }
2946
2947 match ctx.http.fetch_pdf(SOURCE, url.clone()).await {
2948 Ok((body, final_url)) => {
2949 let size_bytes = body.len() as u64;
2950 if let Err(e) = ctx.log.append(RowInput {
2951 event: LogEvent::Fetch,
2952 result: LogResult::Ok,
2953 capability: Capability::Oa,
2954 ref_: Some(doi.as_str()),
2955 source: Some(SOURCE),
2956 error_code: None,
2957 size_bytes: Some(size_bytes),
2958 license: None,
2959 store_path: None,
2960 canonical_digest: Some(&canonical),
2961 }) {
2962 tracing::warn!(error = %e, "appending oa-publisher Fetch ok row failed");
2963 }
2964 Ok((body.to_vec(), final_url))
2965 }
2966 Err(e) => {
2967 match &e {
2968 HttpError::RedirectDenied { host, .. } => {
2969 tracing::info!(
2970 oa_url = %url,
2971 denied_host = %host,
2972 "OA URL host outside oa-publisher allowlist"
2973 );
2974 }
2975 HttpError::NotAPdf { .. } => {
2976 tracing::info!(
2977 oa_url = %url,
2978 "OA URL did not return a PDF magic byte"
2979 );
2980 }
2981 other => {
2982 tracing::warn!(
2983 oa_url = %url,
2984 error = %other,
2985 "OA PDF fetch failed"
2986 );
2987 }
2988 }
2989 let _ = ctx.log.append(RowInput {
3001 event: LogEvent::Fetch,
3002 result: LogResult::Err,
3003 capability: Capability::Oa,
3004 ref_: Some(doi.as_str()),
3005 source: Some(SOURCE),
3006 error_code: Some(crate::ErrorCode::NetworkError.as_wire()),
3007 size_bytes: None,
3008 license: None,
3009 store_path: None,
3010 canonical_digest: Some(&canonical),
3011 });
3012 Err(e)
3013 }
3014 }
3015}
3016
3017#[derive(Default)]
3019pub(crate) struct CrossrefFields {
3020 pub(crate) title: Option<String>,
3021 pub(crate) authors: Vec<String>,
3022 pub(crate) year: Option<i32>,
3023 pub(crate) venue: Option<String>,
3024 pub(crate) volume: Option<String>,
3025 pub(crate) issue: Option<String>,
3026 pub(crate) pages: Option<String>,
3027 pub(crate) type_: Option<String>,
3028 pub(crate) short_venue: Option<String>,
3031}
3032
3033#[cfg(feature = "metadata")]
3044pub(crate) fn extract_datacite_fields(attributes: &Value) -> CrossrefFields {
3045 let title = attributes
3046 .get("titles")
3047 .and_then(|v| v.as_array())
3048 .and_then(|arr| arr.first())
3049 .and_then(|t| t.get("title"))
3050 .and_then(|v| v.as_str())
3051 .map(str::to_string);
3052 let authors = attributes
3053 .get("creators")
3054 .and_then(|v| v.as_array())
3055 .map(|arr| {
3056 arr.iter()
3057 .filter_map(|c| {
3058 c.get("name")
3061 .and_then(|v| v.as_str())
3062 .map(str::to_string)
3063 .or_else(|| {
3064 let given = c.get("givenName").and_then(|v| v.as_str());
3065 let family = c.get("familyName").and_then(|v| v.as_str());
3066 match (given, family) {
3067 (Some(g), Some(f)) => Some(format!("{g} {f}")),
3068 (None, Some(f)) => Some(f.to_string()),
3069 _ => None,
3070 }
3071 })
3072 })
3073 .collect()
3074 })
3075 .unwrap_or_default();
3076 let year = attributes
3077 .get("publicationYear")
3078 .and_then(serde_json::Value::as_i64)
3079 .and_then(|y| i32::try_from(y).ok());
3080 let venue = attributes.get("publisher").and_then(|v| {
3081 v.as_str()
3084 .map(str::to_string)
3085 .or_else(|| v.get("name").and_then(|n| n.as_str()).map(str::to_string))
3086 });
3087 let type_ = crate::sources::datacite::resource_type_general(attributes).map(str::to_string);
3088 CrossrefFields {
3089 title,
3090 authors,
3091 year,
3092 venue,
3093 volume: None,
3094 issue: None,
3095 pages: None,
3096 type_,
3097 short_venue: None,
3098 }
3099}
3100
3101pub(crate) fn extract_crossref_fields(msg: &Value) -> CrossrefFields {
3105 let title = msg
3106 .get("title")
3107 .and_then(|v| v.as_array())
3108 .and_then(|arr| arr.first())
3109 .and_then(|v| v.as_str())
3110 .map(crate::markup::plain_title);
3115
3116 let authors = msg
3117 .get("author")
3118 .and_then(|v| v.as_array())
3119 .map(|arr| {
3120 arr.iter()
3121 .filter_map(|a| {
3122 let family = a.get("family").and_then(|v| v.as_str());
3123 let given = a.get("given").and_then(|v| v.as_str());
3124 match (family, given) {
3125 (Some(f), Some(g)) => Some(format!("{f}, {g}")),
3126 (Some(f), None) => Some(f.to_string()),
3127 (None, Some(g)) => Some(g.to_string()),
3128 _ => None,
3129 }
3130 })
3131 .collect()
3132 })
3133 .unwrap_or_default();
3134
3135 let year = msg
3136 .get("issued")
3137 .and_then(|v| v.get("date-parts"))
3138 .and_then(|v| v.as_array())
3139 .and_then(|arr| arr.first())
3140 .and_then(|v| v.as_array())
3141 .and_then(|arr| arr.first())
3142 .and_then(|v| v.as_i64())
3143 .and_then(|n| i32::try_from(n).ok());
3144
3145 let venue = msg
3146 .get("container-title")
3147 .and_then(|v| v.as_array())
3148 .and_then(|arr| arr.first())
3149 .and_then(|v| v.as_str())
3150 .map(crate::markup::plain_title);
3151
3152 let type_ = msg
3153 .get("type")
3154 .and_then(|v| v.as_str())
3155 .map(|s| s.to_string());
3156
3157 let volume = msg
3158 .get("volume")
3159 .and_then(|v| v.as_str())
3160 .map(|s| s.to_string());
3161
3162 let issue = msg
3163 .get("issue")
3164 .and_then(|v| v.as_str())
3165 .map(|s| s.to_string());
3166
3167 let pages = msg
3170 .get("page")
3171 .and_then(|v| v.as_str())
3172 .map(normalize_page_range);
3173
3174 let short_venue = msg
3175 .get("short-container-title")
3176 .and_then(|v| v.as_array())
3177 .and_then(|arr| arr.first())
3178 .and_then(|v| v.as_str())
3179 .map(|s| crate::markup::plain_title(s.trim()))
3180 .filter(|s| !s.is_empty());
3181
3182 CrossrefFields {
3183 title,
3184 authors,
3185 year,
3186 venue,
3187 volume,
3188 issue,
3189 pages,
3190 type_,
3191 short_venue,
3192 }
3193}
3194
3195fn extract_oa_url_chain(meta: Option<&Value>) -> Vec<url::Url> {
3209 let meta = match meta {
3210 Some(m) => m,
3211 None => return Vec::new(),
3212 };
3213 let mut out: Vec<url::Url> = Vec::new();
3214 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
3215 let mut push_unique = |u: url::Url| {
3216 let key = u.as_str().to_string();
3217 if seen.insert(key) {
3218 out.push(u);
3219 }
3220 };
3221
3222 if let Some(best) = meta.get("best_oa_location") {
3226 if let Some(u) = pull_oa_url_from_location(best) {
3227 push_unique(u);
3228 }
3229 }
3230 if let Some(arr) = meta.get("oa_locations").and_then(|v| v.as_array()) {
3235 for loc in arr {
3236 if let Some(u) = pull_oa_url_from_location(loc) {
3237 push_unique(u);
3238 }
3239 }
3240 }
3241 out
3242}
3243
3244fn pull_oa_url_from_location(loc: &Value) -> Option<url::Url> {
3249 let candidate = loc
3250 .get("url_for_pdf")
3251 .and_then(|v| v.as_str())
3252 .or_else(|| loc.get("url").and_then(|v| v.as_str()))?;
3253 url::Url::parse(candidate).ok()
3254}
3255
3256fn extract_arxiv_id_from_url(url: &url::Url) -> Option<String> {
3262 let host = url.host_str()?;
3263 let is_arxiv = matches!(
3264 host,
3265 "arxiv.org" | "www.arxiv.org" | "export.arxiv.org" | "e-print.arxiv.org"
3266 );
3267 if !is_arxiv {
3268 return None;
3269 }
3270 let path = url.path();
3271 let raw = if path.starts_with("/pdf/") {
3272 let s = path.strip_prefix("/pdf/")?;
3273 s.strip_suffix(".pdf").unwrap_or(s)
3274 } else if path.starts_with("/abs/") {
3275 path.strip_prefix("/abs/")?
3276 } else {
3277 return None;
3278 };
3279 Some(strip_arxiv_version(raw).to_string())
3280}
3281
3282fn strip_arxiv_version(id: &str) -> &str {
3288 if let Some(v_pos) = id.rfind('v') {
3289 let before_v = id[..v_pos].chars().next_back();
3290 let suffix = &id[v_pos + 1..];
3291 if before_v.is_some_and(|c| c.is_ascii_digit())
3292 && !suffix.is_empty()
3293 && suffix.bytes().all(|b| b.is_ascii_digit())
3294 {
3295 return &id[..v_pos];
3296 }
3297 }
3298 id
3299}
3300
3301#[derive(Debug)]
3311pub struct BatchResultEntry {
3312 pub ref_: Ref,
3314 pub outcome: Result<FetchPaperOutcome, FetchError>,
3318}
3319
3320#[derive(Debug)]
3327#[non_exhaustive]
3328pub struct BatchOutcome {
3329 pub results: Vec<BatchResultEntry>,
3331}
3332
3333impl BatchOutcome {
3334 #[doc(hidden)]
3343 #[must_use]
3344 pub fn for_test_synthetic(results: Vec<BatchResultEntry>) -> Self {
3345 Self { results }
3346 }
3347}
3348
3349pub async fn batch_fetch(
3366 refs: &[Ref],
3367 profile: &CapabilityProfile,
3368 ctx: &FetchContext,
3369 store: &dyn Store,
3370 store_root: &Utf8Path,
3371) -> Result<BatchOutcome, FetchError> {
3372 batch_fetch_with(
3373 refs,
3374 profile,
3375 ctx,
3376 store,
3377 store_root,
3378 FetchOptions::default(),
3379 )
3380 .await
3381}
3382
3383pub async fn batch_fetch_with(
3389 refs: &[Ref],
3390 profile: &CapabilityProfile,
3391 ctx: &FetchContext,
3392 store: &dyn Store,
3393 store_root: &Utf8Path,
3394 opts: FetchOptions,
3395) -> Result<BatchOutcome, FetchError> {
3396 if refs.len() > MAX_BATCH_REFS {
3397 return Err(FetchError::TooManyRefs {
3398 got: refs.len(),
3399 max: MAX_BATCH_REFS,
3400 });
3401 }
3402 let mut results = Vec::with_capacity(refs.len());
3403 for ref_ in refs {
3404 let outcome = fetch_paper_with(ref_, profile, ctx, store, store_root, opts).await;
3405 ctx.log.repeat().observe(
3410 ref_.as_input_str(),
3411 match &outcome {
3412 Ok(o) => o.reported_error_code(),
3413 Err(e) => Some(crate::ErrorCode::from(e)),
3414 },
3415 );
3416 results.push(BatchResultEntry {
3417 ref_: ref_.clone(),
3418 outcome,
3419 });
3420 }
3421 Ok(BatchOutcome { results })
3422}
3423
3424pub fn batch_fetch_plans(
3436 refs: &[Ref],
3437 store_root: &Utf8Path,
3438) -> Result<Vec<(Ref, FetchPlan)>, FetchError> {
3439 if refs.len() > MAX_BATCH_REFS {
3440 return Err(FetchError::TooManyRefs {
3441 got: refs.len(),
3442 max: MAX_BATCH_REFS,
3443 });
3444 }
3445 refs.iter()
3446 .map(|r| try_build_fetch_plan(r, store_root).map(|p| (r.clone(), p)))
3447 .collect()
3448}
3449
3450#[cfg(test)]
3455#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
3456mod tests {
3457 use super::*;
3458
3459 struct LadderEnv {
3463 vars: Vec<(&'static str, Option<std::ffi::OsString>)>,
3464 }
3465
3466 impl LadderEnv {
3467 fn scoped(dir: &str) -> Self {
3469 let mut vars = Vec::new();
3470 for v in ["DOIGET_CONTACT_EMAIL", "DOIGET_UNPAYWALL_EMAIL"] {
3471 vars.push((v, std::env::var_os(v)));
3472 std::env::remove_var(v);
3473 }
3474 for v in ["XDG_CONFIG_HOME", "APPDATA", "HOME", "USERPROFILE"] {
3475 vars.push((v, std::env::var_os(v)));
3476 std::env::set_var(v, dir);
3477 }
3478 Self { vars }
3479 }
3480
3481 fn set(&mut self, var: &'static str, value: &str) {
3482 self.vars.push((var, std::env::var_os(var)));
3483 std::env::set_var(var, value);
3484 }
3485 }
3486
3487 impl Drop for LadderEnv {
3488 fn drop(&mut self) {
3489 for (var, prior) in self.vars.iter().rev() {
3490 match prior {
3491 Some(v) => std::env::set_var(var, v),
3492 None => std::env::remove_var(var),
3493 }
3494 }
3495 }
3496 }
3497
3498 fn write_config(td: &tempfile::TempDir, body: &str) -> String {
3500 let dir = camino::Utf8PathBuf::from_path_buf(td.path().to_path_buf())
3501 .expect("temp path is UTF-8");
3502 std::fs::create_dir_all(dir.join("doiget").as_std_path()).expect("mkdir");
3503 std::fs::write(dir.join("doiget").join("config.toml").as_std_path(), body)
3504 .expect("write config.toml");
3505 dir.to_string()
3506 }
3507
3508 #[test]
3517 #[serial_test::serial]
3518 fn resolve_contact_email_reads_the_config_file_rung() {
3519 let td = tempfile::TempDir::new().expect("tempdir");
3520 let dir = write_config(
3521 &td,
3522 "[network]\ncontact_email = \"file@institution.edu\"\nunpaywall_email = \"up@institution.edu\"\n",
3523 );
3524 let _env = LadderEnv::scoped(&dir);
3525
3526 let ContactAddresses {
3527 crossref: contact,
3528 unpaywall,
3529 } = resolve_contact_emails();
3530 assert_eq!(contact, "file@institution.edu");
3531 assert_eq!(unpaywall, "up@institution.edu");
3532 }
3533
3534 #[test]
3537 #[serial_test::serial]
3538 fn the_env_rung_outranks_the_file_and_unpaywall_falls_back_to_contact() {
3539 let td = tempfile::TempDir::new().expect("tempdir");
3540 let dir = write_config(&td, "[network]\ncontact_email = \"file@institution.edu\"\n");
3541 let mut env = LadderEnv::scoped(&dir);
3542 env.set("DOIGET_CONTACT_EMAIL", "env@institution.edu");
3543
3544 let ContactAddresses {
3545 crossref: contact,
3546 unpaywall,
3547 } = resolve_contact_emails();
3548 assert_eq!(contact, "env@institution.edu");
3549 assert_eq!(
3550 unpaywall, "env@institution.edu",
3551 "no unpaywall rung is set, so it must inherit the resolved contact"
3552 );
3553 }
3554
3555 #[test]
3559 #[serial_test::serial]
3560 fn an_absent_config_still_yields_the_documented_fallback() {
3561 let td = tempfile::TempDir::new().expect("tempdir");
3562 let dir = camino::Utf8PathBuf::from_path_buf(td.path().to_path_buf())
3563 .expect("temp path is UTF-8")
3564 .to_string();
3565 let _env = LadderEnv::scoped(&dir);
3566
3567 let ContactAddresses {
3568 crossref: contact,
3569 unpaywall,
3570 } = resolve_contact_emails();
3571 assert_eq!(contact, FALLBACK_CONTACT_EMAIL);
3572 assert_eq!(unpaywall, FALLBACK_CONTACT_EMAIL);
3573 }
3574
3575 #[test]
3579 #[serial_test::serial]
3580 fn a_blank_value_on_either_rung_is_treated_as_unset() {
3581 let td = tempfile::TempDir::new().expect("tempdir");
3582 let dir = write_config(&td, "[network]\ncontact_email = \" \"\n");
3583 let mut env = LadderEnv::scoped(&dir);
3584 env.set("DOIGET_CONTACT_EMAIL", "");
3585
3586 assert_eq!(resolve_contact_email(), FALLBACK_CONTACT_EMAIL);
3587 }
3588
3589 fn crossref_outcome() -> MetadataOnlyOutcome {
3593 MetadataOnlyOutcome {
3594 metadata_quality: Vec::new(),
3595 repaired_fields: std::collections::BTreeMap::new(),
3596 source: "crossref".to_string(),
3597 resolver_profile: "crossref".to_string(),
3598 license: None,
3599 oa_url: None,
3600 oa_status: None,
3601 metadata: serde_json::json!({
3602 "title": ["Rigorous results on valence-bond ground states"],
3603 "author": [
3604 { "family": "Affleck", "given": "Ian" },
3605 { "family": "Lieb", "given": "Elliott H." },
3606 ],
3607 "issued": { "date-parts": [[1988, 6, 1]] },
3608 "container-title": ["Physical Review Letters"],
3609 "publisher": "American Physical Society",
3610 "ISSN": ["0031-9007", "1079-7114"],
3611 "volume": "59",
3612 "issue": "7",
3613 "page": "799-802",
3614 "type": "journal-article",
3615 }),
3616 }
3617 }
3618
3619 #[test]
3620 fn cite_metadata_enriches_from_crossref_envelope() {
3621 let ref_ = Ref::parse("10.1103/PhysRevLett.59.799").unwrap();
3622 let m = cite_metadata(&ref_, &crossref_outcome());
3623 assert_eq!(m.title, "Rigorous results on valence-bond ground states");
3624 assert_eq!(m.authors, vec!["Affleck, Ian", "Lieb, Elliott H."]);
3625 assert_eq!(m.year, Some(1988));
3626 assert_eq!(m.venue.as_deref(), Some("Physical Review Letters"));
3627 assert_eq!(m.publisher.as_deref(), Some("American Physical Society"));
3628 assert_eq!(m.issn.as_deref(), Some("0031-9007"));
3630 assert_eq!(m.volume.as_deref(), Some("59"));
3631 assert_eq!(m.issue.as_deref(), Some("7"));
3632 assert_eq!(m.pages.as_deref(), Some("799--802"));
3634 assert_eq!(m.type_.as_deref(), Some("journal-article"));
3635 }
3636
3637 #[test]
3638 fn cite_metadata_non_crossref_keeps_minimal_baseline() {
3639 let ref_ = Ref::parse("arxiv:2401.12345").unwrap();
3643 let outcome = MetadataOnlyOutcome {
3644 metadata_quality: Vec::new(),
3645 repaired_fields: std::collections::BTreeMap::new(),
3646 source: "arxiv".to_string(),
3647 resolver_profile: "arxiv".to_string(),
3648 license: Some("arxiv-default".to_string()),
3649 oa_url: None,
3650 oa_status: Some("green".to_string()),
3651 metadata: serde_json::json!({ "title": "An arXiv Preprint" }),
3652 };
3653 let m = cite_metadata(&ref_, &outcome);
3654 assert_eq!(m.title, "An arXiv Preprint");
3655 assert_eq!(m.year, None);
3656 assert_eq!(m.venue, None);
3657 assert_eq!(m.publisher, None);
3658 assert_eq!(m.issn, None);
3659 assert!(m.arxiv_id.is_some());
3660 }
3661
3662 #[test]
3663 fn cite_metadata_arxiv_overlay_fills_year_and_categories() {
3664 let ref_ = Ref::parse("arxiv:2401.12345").unwrap();
3668 let outcome = MetadataOnlyOutcome {
3669 metadata_quality: Vec::new(),
3670 repaired_fields: std::collections::BTreeMap::new(),
3671 source: "arxiv".to_string(),
3672 resolver_profile: "arxiv".to_string(),
3673 license: Some("arxiv-default".to_string()),
3674 oa_url: None,
3675 oa_status: Some("green".to_string()),
3676 metadata: serde_json::json!({
3677 "title": "An arXiv Preprint",
3678 "published": "2024-03-15T00:00:00Z",
3679 "categories": ["cond-mat.str-el", "cond-mat.dis-nn"],
3680 }),
3681 };
3682 let m = cite_metadata(&ref_, &outcome);
3683 assert_eq!(m.year, Some(2024));
3684 assert_eq!(
3685 m.arxiv_categories,
3686 vec!["cond-mat.str-el".to_string(), "cond-mat.dis-nn".to_string()]
3687 );
3688
3689 let bad = MetadataOnlyOutcome {
3691 metadata_quality: Vec::new(),
3692 repaired_fields: std::collections::BTreeMap::new(),
3693 metadata: serde_json::json!({ "title": "x", "published": "not-a-date" }),
3694 ..outcome
3695 };
3696 assert_eq!(cite_metadata(&ref_, &bad).year, None);
3697 }
3698
3699 #[test]
3700 fn test_extract_arxiv_id_from_url() {
3701 let urls = [
3702 ("https://arxiv.org/pdf/1901.12345.pdf", Some("1901.12345")),
3704 ("https://arxiv.org/abs/1901.12345", Some("1901.12345")),
3705 ("https://arxiv.org/pdf/1901.12345v2.pdf", Some("1901.12345")),
3707 ("https://arxiv.org/abs/1901.12345v3", Some("1901.12345")),
3708 (
3710 "https://www.arxiv.org/pdf/cond-mat/9501001.pdf",
3711 Some("cond-mat/9501001"),
3712 ),
3713 (
3714 "https://export.arxiv.org/abs/cond-mat/9501001",
3715 Some("cond-mat/9501001"),
3716 ),
3717 (
3719 "https://arxiv.org/pdf/cond-mat/9501001v1.pdf",
3720 Some("cond-mat/9501001"),
3721 ),
3722 (
3724 "https://e-print.arxiv.org/pdf/2401.12345.pdf",
3725 Some("2401.12345"),
3726 ),
3727 ("https://example.org/pdf/1901.12345.pdf", None),
3729 ];
3730 for (url_str, expected) in urls {
3731 let url = url::Url::parse(url_str).unwrap();
3732 assert_eq!(
3733 extract_arxiv_id_from_url(&url),
3734 expected.map(String::from),
3735 "url: {url_str}"
3736 );
3737 }
3738 }
3739
3740 #[test]
3741 fn test_strip_arxiv_version() {
3742 assert_eq!(strip_arxiv_version("2401.12345v2"), "2401.12345");
3743 assert_eq!(strip_arxiv_version("2401.12345v10"), "2401.12345");
3744 assert_eq!(strip_arxiv_version("2401.12345"), "2401.12345");
3745 assert_eq!(
3746 strip_arxiv_version("cond-mat/9501001v3"),
3747 "cond-mat/9501001"
3748 );
3749 assert_eq!(strip_arxiv_version("quant-phv5"), "quant-phv5");
3751 }
3752
3753 #[test]
3754 fn extract_crossref_publisher_url_takes_a_general_entry() {
3755 let msg = serde_json::json!({
3756 "link": [
3757 {"URL": "https://example.org/free.pdf",
3758 "intended-application": "unspecified"},
3759 {"URL": "https://example.org/alt.pdf",
3760 "intended-application": "unspecified"}
3761 ]
3762 });
3763 assert_eq!(
3764 extract_crossref_publisher_url(&msg),
3765 Some("https://example.org/free.pdf".to_string())
3766 );
3767 }
3768
3769 #[test]
3770 fn extract_crossref_publisher_url_returns_none_when_absent() {
3771 assert!(extract_crossref_publisher_url(&serde_json::json!({})).is_none());
3772 }
3773
3774 #[test]
3775 fn extract_crossref_publisher_url_skips_empty_url_strings() {
3776 let msg = serde_json::json!({
3777 "link": [
3778 {"URL": "", "intended-application": "unspecified"},
3779 {"URL": "https://example.org/real.pdf",
3780 "intended-application": "unspecified"}
3781 ]
3782 });
3783 assert_eq!(
3784 extract_crossref_publisher_url(&msg),
3785 Some("https://example.org/real.pdf".to_string())
3786 );
3787 }
3788
3789 #[test]
3800 fn extract_crossref_publisher_url_refuses_programme_scoped_links() {
3801 for scoped in ["text-mining", "similarity-checking", "syndication"] {
3802 let msg = serde_json::json!({
3803 "link": [{"URL": "https://example.org/scoped.pdf",
3804 "intended-application": scoped}]
3805 });
3806 assert_eq!(
3807 extract_crossref_publisher_url(&msg),
3808 None,
3809 "{scoped} is scoped to a programme doiget is not in on this path"
3810 );
3811 }
3812 }
3813
3814 #[test]
3818 fn extract_crossref_publisher_url_refuses_an_unlabelled_entry() {
3819 let msg = serde_json::json!({"link": [{"URL": "https://example.org/x.pdf"}]});
3820 assert_eq!(extract_crossref_publisher_url(&msg), None);
3821 }
3822
3823 #[test]
3826 fn extract_crossref_publisher_url_prefers_a_pdf_content_type() {
3827 let msg = serde_json::json!({
3828 "link": [
3829 {"URL": "https://example.org/landing",
3830 "content-type": "text/html",
3831 "intended-application": "unspecified"},
3832 {"URL": "https://example.org/real.pdf",
3833 "content-type": "application/pdf",
3834 "intended-application": "unspecified"}
3835 ]
3836 });
3837 assert_eq!(
3838 extract_crossref_publisher_url(&msg),
3839 Some("https://example.org/real.pdf".to_string())
3840 );
3841 }
3842
3843 #[test]
3844 fn extract_unpaywall_oa_url_prefers_url_for_pdf() {
3845 let meta = serde_json::json!({
3846 "best_oa_location": {
3847 "url_for_pdf": "https://example.org/pdf",
3848 "url": "https://example.org/landing"
3849 }
3850 });
3851 assert_eq!(
3852 extract_unpaywall_oa_url(&meta),
3853 Some("https://example.org/pdf".to_string())
3854 );
3855 }
3856
3857 #[test]
3858 fn extract_unpaywall_oa_url_falls_back_to_url() {
3859 let meta = serde_json::json!({
3860 "best_oa_location": {
3861 "url": "https://example.org/landing"
3862 }
3863 });
3864 assert_eq!(
3865 extract_unpaywall_oa_url(&meta),
3866 Some("https://example.org/landing".to_string())
3867 );
3868 }
3869
3870 #[test]
3871 fn extract_unpaywall_oa_url_returns_none_when_absent() {
3872 let meta = serde_json::json!({});
3873 assert!(extract_unpaywall_oa_url(&meta).is_none());
3874 }
3875
3876 #[test]
3877 fn extract_unpaywall_oa_status_present_absent_and_empty() {
3878 assert_eq!(
3881 extract_unpaywall_oa_status(&serde_json::json!({"oa_status": "gold"})).as_deref(),
3882 Some("gold")
3883 );
3884 assert!(extract_unpaywall_oa_status(&serde_json::json!({})).is_none());
3885 assert!(extract_unpaywall_oa_status(&serde_json::json!({"oa_status": ""})).is_none());
3886 }
3887
3888 #[test]
3898 fn extract_crossref_fields_parses_minimal_shape() {
3899 let msg = serde_json::json!({
3900 "title": ["Example Title"],
3901 "author": [{ "family": "Smith", "given": "Alice" }],
3902 "issued": { "date-parts": [[2024, 1, 15]] },
3903 "container-title": ["Phys. Rev. X"],
3904 "type": "journal-article"
3905 });
3906 let f = extract_crossref_fields(&msg);
3907 assert_eq!(f.title.as_deref(), Some("Example Title"));
3908 assert_eq!(f.authors, vec!["Smith, Alice".to_string()]);
3909 assert_eq!(f.year, Some(2024));
3910 assert_eq!(f.venue.as_deref(), Some("Phys. Rev. X"));
3911 assert_eq!(f.type_.as_deref(), Some("journal-article"));
3912 }
3913
3914 #[test]
3915 fn extract_crossref_fields_tolerates_missing() {
3916 let f = extract_crossref_fields(&serde_json::json!({}));
3917 assert!(f.title.is_none());
3918 assert!(f.authors.is_empty());
3919 assert!(f.year.is_none());
3920 assert!(f.venue.is_none());
3921 assert!(f.type_.is_none());
3922 }
3923
3924 #[test]
3925 fn extract_oa_url_chain_prefers_best_url_for_pdf() {
3926 let meta = serde_json::json!({
3931 "best_oa_location": {
3932 "url_for_pdf": "https://example.org/pdf",
3933 "url": "https://example.org/landing"
3934 }
3935 });
3936 let chain = extract_oa_url_chain(Some(&meta));
3937 assert_eq!(chain.len(), 1);
3938 assert_eq!(chain[0].as_str(), "https://example.org/pdf");
3939 }
3940
3941 #[test]
3942 fn extract_oa_url_chain_falls_back_to_url_when_url_for_pdf_absent() {
3943 let meta = serde_json::json!({
3944 "best_oa_location": {
3945 "url": "https://example.org/landing"
3946 }
3947 });
3948 let chain = extract_oa_url_chain(Some(&meta));
3949 assert_eq!(chain.len(), 1);
3950 assert_eq!(chain[0].as_str(), "https://example.org/landing");
3951 }
3952
3953 #[test]
3954 fn extract_oa_url_chain_is_empty_when_no_locations() {
3955 let meta = serde_json::json!({});
3956 assert!(extract_oa_url_chain(Some(&meta)).is_empty());
3957 assert!(extract_oa_url_chain(None).is_empty());
3958 }
3959
3960 #[test]
3961 fn extract_oa_url_chain_appends_oa_locations_after_best() {
3962 let meta = serde_json::json!({
3968 "best_oa_location": {
3969 "url_for_pdf": "https://publisher.example.org/pdf"
3970 },
3971 "oa_locations": [
3972 {"url_for_pdf": "https://publisher.example.org/pdf"},
3973 {"url_for_pdf": "https://arxiv.org/pdf/2401.12345"},
3974 {"url": "https://repo.example.edu/handle/123"}
3975 ]
3976 });
3977 let chain = extract_oa_url_chain(Some(&meta));
3978 let strs: Vec<&str> = chain.iter().map(|u| u.as_str()).collect();
3979 assert_eq!(
3980 strs,
3981 vec![
3982 "https://publisher.example.org/pdf",
3983 "https://arxiv.org/pdf/2401.12345",
3984 "https://repo.example.edu/handle/123",
3985 ],
3986 "chain ordering MUST be best_oa_location first, oa_locations[] verbatim after"
3987 );
3988 }
3989
3990 #[test]
3991 fn extract_oa_url_chain_dedupes_repeated_urls() {
3992 let meta = serde_json::json!({
3997 "best_oa_location": {
3998 "url_for_pdf": "https://example.org/pdf"
3999 },
4000 "oa_locations": [
4001 {"url_for_pdf": "https://example.org/pdf"},
4002 {"url_for_pdf": "https://example.org/pdf"},
4003 {"url_for_pdf": "https://arxiv.org/pdf/2401.12345"}
4004 ]
4005 });
4006 let chain = extract_oa_url_chain(Some(&meta));
4007 assert_eq!(chain.len(), 2);
4008 assert_eq!(chain[0].as_str(), "https://example.org/pdf");
4009 assert_eq!(chain[1].as_str(), "https://arxiv.org/pdf/2401.12345");
4010 }
4011
4012 #[test]
4013 fn extract_oa_url_chain_skips_unparsable_urls() {
4014 let meta = serde_json::json!({
4018 "best_oa_location": {
4019 "url_for_pdf": "https://good.example.org/pdf"
4020 },
4021 "oa_locations": [
4022 {"url_for_pdf": "not a url"},
4023 {"url_for_pdf": "https://arxiv.org/pdf/2401.12345"}
4024 ]
4025 });
4026 let chain = extract_oa_url_chain(Some(&meta));
4027 assert_eq!(chain.len(), 2);
4028 assert_eq!(chain[0].as_str(), "https://good.example.org/pdf");
4029 assert_eq!(chain[1].as_str(), "https://arxiv.org/pdf/2401.12345");
4030 }
4031
4032 #[test]
4033 fn fetch_paper_plan_matches_build_fetch_plan() {
4034 use crate::{ArxivId, Doi};
4038 let r = Ref::Doi(Doi("10.1234/example".to_string()));
4039 let root = Utf8PathBuf::from("/tmp/doiget-test");
4040 let plan_a = fetch_paper_plan(&r, &root);
4041 let plan_b = build_fetch_plan(&r, &root);
4042 assert_eq!(plan_a.metadata_sources, plan_b.metadata_sources);
4043 assert_eq!(plan_a.target_pdf_path, plan_b.target_pdf_path);
4044 assert_eq!(plan_a.target_metadata_path, plan_b.target_metadata_path);
4045
4046 let r2 = Ref::Arxiv(ArxivId("2401.12345".to_string()));
4047 let plan_c = fetch_paper_plan(&r2, &root);
4048 let plan_d = build_fetch_plan(&r2, &root);
4049 assert_eq!(plan_c.pdf_sources[0].key, plan_d.pdf_sources[0].key);
4050 }
4051
4052 #[test]
4053 fn batch_fetch_plans_returns_plan_per_ref_in_order() {
4054 use crate::{ArxivId, Doi};
4055 let refs = vec![
4056 Ref::Doi(Doi("10.1234/alpha".to_string())),
4057 Ref::Arxiv(ArxivId("2401.12345".to_string())),
4058 ];
4059 let root = Utf8PathBuf::from("/tmp/doiget-batch-test");
4060 let plans = batch_fetch_plans(&refs, &root).expect("under cap returns Ok");
4061 assert_eq!(plans.len(), 2);
4062 assert!(matches!(plans[0].0, Ref::Doi(_)));
4064 assert!(matches!(plans[1].0, Ref::Arxiv(_)));
4065 assert_eq!(plans[0].1.metadata_sources, vec!["crossref", "unpaywall"]);
4067 assert_eq!(plans[1].1.pdf_sources[0].key, "arxiv");
4069 }
4070
4071 #[test]
4072 fn batch_fetch_plans_too_many_refs_returns_err() {
4073 use crate::Doi;
4074 let n = MAX_BATCH_REFS + 1;
4076 let refs: Vec<Ref> = (0..n)
4077 .map(|i| Ref::Doi(Doi(format!("10.1234/n{}", i))))
4078 .collect();
4079 let root = Utf8PathBuf::from("/tmp/doiget-toomany");
4080 let err = batch_fetch_plans(&refs, &root).expect_err("over cap returns Err");
4081 match err {
4082 FetchError::TooManyRefs { got, max } => {
4083 assert_eq!(got, n);
4084 assert_eq!(max, MAX_BATCH_REFS);
4085 }
4086 other => panic!("expected TooManyRefs, got: {other:?}"),
4087 }
4088 }
4089
4090 #[tokio::test]
4091 async fn batch_fetch_too_many_refs_returns_err_before_any_fetch() {
4092 use crate::http::{tier_1_allowlist, HttpClient};
4096 use crate::provenance::ProvenanceLog;
4097 use crate::rate_limiter::RateLimiter;
4098 use crate::store::FsStore;
4099 use crate::{Doi, RateLimits};
4100 use std::sync::Arc;
4101
4102 let td = tempfile::TempDir::new().expect("tempdir");
4103 let log_path = Utf8Path::from_path(td.path())
4104 .expect("utf-8")
4105 .join("log.jsonl");
4106 let store_root = Utf8Path::from_path(td.path())
4107 .expect("utf-8")
4108 .join("papers");
4109
4110 let ctx = FetchContext {
4111 http: Arc::new(HttpClient::new(tier_1_allowlist()).expect("http client")),
4112 rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
4113 log: Arc::new(
4114 ProvenanceLog::open(log_path, "01J0000000000000000000TEST".into())
4115 .expect("provenance log"),
4116 ),
4117 session_id: "01J0000000000000000000TEST".into(),
4118 cache_root: None,
4119 };
4120 let profile = CapabilityProfile::for_tests();
4121 let store = FsStore::new(store_root.clone()).expect("fs store");
4122
4123 let n = MAX_BATCH_REFS + 1;
4124 let refs: Vec<Ref> = (0..n)
4125 .map(|i| Ref::Doi(Doi(format!("10.1234/n{}", i))))
4126 .collect();
4127
4128 let err = batch_fetch(&refs, &profile, &ctx, &store, &store_root)
4129 .await
4130 .expect_err("over cap returns Err");
4131 match err {
4132 FetchError::TooManyRefs { got, max } => {
4133 assert_eq!(got, n);
4134 assert_eq!(max, MAX_BATCH_REFS);
4135 }
4136 other => panic!("expected TooManyRefs, got: {other:?}"),
4137 }
4138 }
4139
4140 #[tokio::test]
4146 async fn try_fetch_oa_pdf_non_pdf_body_is_err_not_silent_none() {
4147 use crate::http::HttpClient;
4148 use crate::provenance::ProvenanceLog;
4149 use crate::rate_limiter::RateLimiter;
4150 use crate::{Doi, RateLimits};
4151 use std::sync::Arc;
4152 use wiremock::matchers::method;
4153 use wiremock::{Mock, MockServer, ResponseTemplate};
4154
4155 let server = MockServer::start().await;
4156 Mock::given(method("GET"))
4157 .respond_with(
4158 ResponseTemplate::new(200).set_body_bytes(b"<html>not a pdf</html>".to_vec()),
4159 )
4160 .mount(&server)
4161 .await;
4162 let host = server
4163 .uri()
4164 .parse::<url::Url>()
4165 .expect("uri")
4166 .host_str()
4167 .expect("host")
4168 .to_string();
4169
4170 let td = tempfile::TempDir::new().expect("tempdir");
4171 let log_path = Utf8Path::from_path(td.path())
4172 .expect("utf-8")
4173 .join("log.jsonl");
4174 let ctx = FetchContext {
4175 http: Arc::new(HttpClient::new_for_tests_allow_http("oa-publisher", &host)),
4176 rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
4177 log: Arc::new(
4178 ProvenanceLog::open(log_path, "01J0000000000000000000TEST".into())
4179 .expect("provenance log"),
4180 ),
4181 session_id: "01J0000000000000000000TEST".into(),
4182 cache_root: None,
4183 };
4184
4185 let doi = Doi("10.1234/example".to_string());
4186 let url: url::Url = format!("{}/oa.pdf", server.uri()).parse().expect("url");
4187 let res = try_fetch_oa_pdf(&doi, &url, &ctx).await;
4188 match res {
4189 Err(HttpError::NotAPdf { .. }) => {}
4190 other => panic!("expected Err(NotAPdf), got: {other:?}"),
4191 }
4192 }
4193
4194 #[tokio::test]
4207 async fn try_fetch_oa_pdf_off_allowlist_host_no_redirect_is_redirect_denied_145() {
4208 use crate::http::HttpClient;
4209 use crate::provenance::ProvenanceLog;
4210 use crate::rate_limiter::RateLimiter;
4211 use crate::{DenialContext, DenialReason, Doi, RateLimits};
4212 use std::sync::Arc;
4213 use wiremock::matchers::method;
4214 use wiremock::{Mock, MockServer, ResponseTemplate};
4215
4216 let server = MockServer::start().await;
4220 Mock::given(method("GET"))
4221 .respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.7 real pdf".to_vec()))
4222 .mount(&server)
4223 .await;
4224
4225 let td = tempfile::TempDir::new().expect("tempdir");
4229 let log_path = Utf8Path::from_path(td.path())
4230 .expect("utf-8")
4231 .join("log.jsonl");
4232 let ctx = FetchContext {
4233 http: Arc::new(HttpClient::new_for_tests_allow_http(
4234 "oa-publisher",
4235 "allowed-publisher.example.com",
4236 )),
4237 rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
4238 log: Arc::new(
4239 ProvenanceLog::open(log_path.clone(), "01J0000000000000000000TEST".into())
4240 .expect("provenance log"),
4241 ),
4242 session_id: "01J0000000000000000000TEST".into(),
4243 cache_root: None,
4244 };
4245
4246 let doi = Doi("10.1234/example".to_string());
4247 let off_host_url: url::Url = format!("{}/oa.pdf", server.uri()).parse().expect("url");
4250 let res = try_fetch_oa_pdf(&doi, &off_host_url, &ctx).await;
4251
4252 let err = match res {
4254 Err(e @ HttpError::RedirectDenied { .. }) => e,
4255 other => {
4256 panic!("expected Err(RedirectDenied) from the pre-fetch check, got: {other:?}")
4257 }
4258 };
4259 match &err {
4260 HttpError::RedirectDenied {
4261 source_key,
4262 host,
4263 expected_hosts,
4264 } => {
4265 assert_eq!(source_key, "oa-publisher");
4266 assert_eq!(
4269 host,
4270 off_host_url
4271 .host_str()
4272 .expect("wiremock host")
4273 .to_ascii_lowercase()
4274 .as_str()
4275 );
4276 assert_eq!(
4277 expected_hosts,
4278 &vec!["allowed-publisher.example.com".to_string()]
4279 );
4280 }
4281 _ => unreachable!(),
4282 }
4283
4284 assert!(
4287 server
4288 .received_requests()
4289 .await
4290 .unwrap_or_default()
4291 .is_empty(),
4292 "the off-allowlist OA URL must NOT be fetched: the pre-check \
4293 (REDIRECT_ALLOWLIST.md §1) rejects it before any request is \
4294 issued; wiremock recorded request(s)",
4295 );
4296
4297 let dc: Option<DenialContext> = (&err).into();
4301 let dc = dc.expect("pre-fetch RedirectDenied -> Some(DenialContext)");
4302 assert_eq!(dc.reason, DenialReason::RedirectNotInAllowlist);
4303 assert_eq!(dc.source.as_deref(), Some("oa-publisher"));
4304 assert_eq!(
4305 dc.attempted,
4306 Some(off_host_url.host_str().expect("host").to_ascii_lowercase()),
4307 "attempted host must be the rejected OA URL host, lowercased — \
4308 identical to what the redirect closure records",
4309 );
4310 assert_eq!(
4311 dc.expected,
4312 Some(vec!["allowed-publisher.example.com".to_string()]),
4313 );
4314
4315 let log_txt = std::fs::read_to_string(&log_path).expect("read provenance log");
4319 let fetch_err_row = log_txt
4320 .lines()
4321 .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
4322 .find(|v| {
4323 v.get("event").and_then(|e| e.as_str()) == Some("fetch")
4324 && v.get("result").and_then(|r| r.as_str()) == Some("err")
4325 })
4326 .expect("a Fetch/err provenance row was written");
4327 assert_eq!(
4328 fetch_err_row.get("source").and_then(|s| s.as_str()),
4329 Some("oa-publisher"),
4330 );
4331 assert_eq!(
4332 fetch_err_row.get("error_code").and_then(|c| c.as_str()),
4333 Some("NETWORK_ERROR"),
4334 );
4335 assert_eq!(
4336 fetch_err_row.get("ref").and_then(|r| r.as_str()),
4337 Some("10.1234/example"),
4338 );
4339 }
4340
4341 #[tokio::test]
4345 async fn try_fetch_oa_pdf_on_allowlist_host_still_fetches_pdf_no_regression_145() {
4346 use crate::http::HttpClient;
4347 use crate::provenance::ProvenanceLog;
4348 use crate::rate_limiter::RateLimiter;
4349 use crate::{Doi, RateLimits};
4350 use std::sync::Arc;
4351 use wiremock::matchers::method;
4352 use wiremock::{Mock, MockServer, ResponseTemplate};
4353
4354 let server = MockServer::start().await;
4355 let body = b"%PDF-1.7\nhello pdf".to_vec();
4356 Mock::given(method("GET"))
4357 .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
4358 .mount(&server)
4359 .await;
4360 let host = server
4363 .uri()
4364 .parse::<url::Url>()
4365 .expect("uri")
4366 .host_str()
4367 .expect("host")
4368 .to_string();
4369
4370 let td = tempfile::TempDir::new().expect("tempdir");
4371 let log_path = Utf8Path::from_path(td.path())
4372 .expect("utf-8")
4373 .join("log.jsonl");
4374 let ctx = FetchContext {
4375 http: Arc::new(HttpClient::new_for_tests_allow_http("oa-publisher", &host)),
4376 rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
4377 log: Arc::new(
4378 ProvenanceLog::open(log_path, "01J0000000000000000000TEST".into())
4379 .expect("provenance log"),
4380 ),
4381 session_id: "01J0000000000000000000TEST".into(),
4382 cache_root: None,
4383 };
4384
4385 let doi = Doi("10.1234/example".to_string());
4386 let url: url::Url = format!("{}/oa.pdf", server.uri()).parse().expect("url");
4387 let (bytes, _final_url) = try_fetch_oa_pdf(&doi, &url, &ctx)
4388 .await
4389 .expect("on-allowlist OA URL still fetches the PDF");
4390 assert_eq!(bytes, body, "PDF bytes must be returned unchanged");
4391 }
4392
4393 #[test]
4400 fn pre_fetch_denial_produces_byte_identical_denial_context_as_redirect_denied_145() {
4401 use crate::{DenialContext, DenialReason};
4402
4403 let pre_fetch = HttpError::RedirectDenied {
4405 source_key: "oa-publisher".to_string(),
4406 host: "attacker.test".to_string(),
4407 expected_hosts: vec!["*.springer.com".to_string(), "*.plos.org".to_string()],
4408 };
4409 let redirect_closure = HttpError::RedirectDenied {
4412 source_key: "oa-publisher".to_string(),
4413 host: "attacker.test".to_string(),
4414 expected_hosts: vec!["*.springer.com".to_string(), "*.plos.org".to_string()],
4415 };
4416
4417 let dc_pre: Option<DenialContext> = (&pre_fetch).into();
4418 let dc_red: Option<DenialContext> = (&redirect_closure).into();
4419 let dc_pre = dc_pre.expect("pre-fetch -> Some");
4420 let dc_red = dc_red.expect("redirect -> Some");
4421
4422 assert_eq!(dc_pre, dc_red);
4425 assert_eq!(dc_pre.reason, DenialReason::RedirectNotInAllowlist);
4426 assert_eq!(dc_pre.source.as_deref(), Some("oa-publisher"));
4427 assert_eq!(dc_pre.attempted.as_deref(), Some("attacker.test"));
4428 assert_eq!(
4429 dc_pre.expected,
4430 Some(vec!["*.springer.com".to_string(), "*.plos.org".to_string()]),
4431 );
4432 assert_eq!(dc_pre.hop_index, None);
4433 assert_eq!(dc_pre.cap, None);
4434 assert_eq!(dc_pre.actual, None);
4435 }
4436
4437 async fn md139_harness() -> (
4447 wiremock::MockServer,
4448 FetchContext,
4449 crate::store::FsStore,
4450 Utf8PathBuf,
4451 tempfile::TempDir,
4452 ) {
4453 md139_harness_with(
4454 r#"{"status":"ok","message":{"title":["Example Paper"],"author":[{"given":"Ada","family":"Lovelace"}]}}"#,
4455 )
4456 .await
4457 }
4458
4459 async fn md139_harness_with(
4461 crossref_body: &str,
4462 ) -> (
4463 wiremock::MockServer,
4464 FetchContext,
4465 crate::store::FsStore,
4466 Utf8PathBuf,
4467 tempfile::TempDir,
4468 ) {
4469 use crate::http::HttpClient;
4470 use crate::provenance::ProvenanceLog;
4471 use crate::rate_limiter::RateLimiter;
4472 use crate::store::FsStore;
4473 use crate::RateLimits;
4474 use std::sync::Arc;
4475 use wiremock::matchers::method;
4476 use wiremock::{Mock, MockServer, ResponseTemplate};
4477
4478 let server = MockServer::start().await;
4479 Mock::given(method("GET"))
4480 .respond_with(ResponseTemplate::new(200).set_body_string(crossref_body))
4481 .mount(&server)
4482 .await;
4483 std::env::set_var("DOIGET_CROSSREF_BASE", server.uri());
4484
4485 let host = server
4489 .uri()
4490 .parse::<url::Url>()
4491 .expect("uri")
4492 .host_str()
4493 .expect("host")
4494 .to_string();
4495
4496 let td = tempfile::TempDir::new().expect("tempdir");
4497 let base = Utf8Path::from_path(td.path()).expect("utf-8");
4498 let log_path = base.join("log.jsonl");
4499 let store_root = base.join("papers");
4500 let ctx = FetchContext {
4501 http: Arc::new(HttpClient::new_for_tests_allow_http_multi(&[
4502 ("crossref", &host),
4503 ("unpaywall", &host),
4504 ])),
4505 rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
4506 log: Arc::new(
4507 ProvenanceLog::open(log_path, "01J0000000000000000000TEST".into())
4508 .expect("provenance log"),
4509 ),
4510 session_id: "01J0000000000000000000TEST".into(),
4511 cache_root: None,
4512 };
4513 let store = FsStore::new(store_root.clone()).expect("fs store");
4514 (server, ctx, store, store_root, td)
4515 }
4516
4517 fn metadata_dir_tomls(store_root: &Utf8Path) -> Vec<Utf8PathBuf> {
4518 let md = store_root.join(".metadata");
4519 match std::fs::read_dir(md.as_std_path()) {
4520 Ok(rd) => rd
4521 .filter_map(|e| e.ok())
4522 .filter_map(|e| Utf8PathBuf::from_path_buf(e.path()).ok())
4523 .filter(|p| p.extension() == Some("toml"))
4524 .collect(),
4525 Err(_) => Vec::new(),
4526 }
4527 }
4528
4529 #[tokio::test]
4530 #[serial_test::serial]
4531 async fn metadata_only_to_store_writes_metadata_toml_139() {
4532 let (_server, ctx, store, store_root, _td) = md139_harness().await;
4533 let profile = CapabilityProfile::from_env().expect("clean env");
4534 let ref_ = Ref::Doi(Doi("10.1234/example".to_string()));
4535
4536 let outcome = metadata_only_to_store(&ref_, &profile, &ctx, &store)
4537 .await
4538 .expect("metadata_only_to_store ok");
4539 assert_eq!(outcome.source, "crossref");
4540
4541 let tomls = metadata_dir_tomls(&store_root);
4542 assert_eq!(
4543 tomls.len(),
4544 1,
4545 "exactly one .metadata/*.toml must be written (MCP_TOOLS.md §11 SIDE EFFECT, #139); got {tomls:?}"
4546 );
4547 let body = std::fs::read_to_string(&tomls[0]).expect("read metadata toml");
4548 let meta: crate::store::Metadata = toml::from_str(&body).expect("parse metadata toml");
4549 assert_eq!(meta.title, "Example Paper");
4550 assert_eq!(
4551 meta.doi.as_ref().map(|d| d.as_str()),
4552 Some("10.1234/example")
4553 );
4554 let ext = meta.doiget.expect("[doiget] table present");
4555 assert_eq!(ext.source, "crossref");
4556 assert_eq!(ext.size_bytes, 0, "metadata-only entry has no PDF");
4557
4558 std::env::remove_var("DOIGET_CROSSREF_BASE");
4559 }
4560
4561 #[tokio::test]
4566 #[serial_test::serial]
4567 async fn a_pretty_printed_crossref_title_is_stored_as_plain_text_609() {
4568 let body = serde_json::json!({"status": "ok", "message": {
4569 "title": ["Recent developments in the P\n <scp>y</scp>\n SCF program package"],
4570 "author": [{"given": "Qiming", "family": "Sun"}]
4571 }})
4572 .to_string();
4573 let (_server, ctx, store, store_root, _td) = md139_harness_with(&body).await;
4574 let profile = CapabilityProfile::from_env().expect("clean env");
4575 let ref_ = Ref::Doi(Doi("10.1063/5.0006074".to_string()));
4576 metadata_only_to_store(&ref_, &profile, &ctx, &store)
4577 .await
4578 .expect("metadata_only_to_store ok");
4579 let tomls = metadata_dir_tomls(&store_root);
4580 let body = std::fs::read_to_string(&tomls[0]).expect("read metadata toml");
4581 let meta: crate::store::Metadata = toml::from_str(&body).expect("parse metadata toml");
4582 assert_eq!(
4583 meta.title,
4584 "Recent developments in the PySCF program package"
4585 );
4586 std::env::remove_var("DOIGET_CROSSREF_BASE");
4587 }
4588
4589 #[tokio::test]
4593 #[serial_test::serial]
4594 async fn metadata_only_to_store_reports_an_unrepaired_replacement_char_608() {
4595 let body = serde_json::json!({"status": "ok", "message": {
4596 "title": ["N\u{FFFD}herungsmethode zur L\u{FFFD}sung"],
4597 "author": [{"given": "V.", "family": "Fock"}]
4598 }})
4599 .to_string();
4600 let (_server, ctx, store, store_root, _td) = md139_harness_with(&body).await;
4601 let profile = CapabilityProfile::from_env().expect("clean env");
4602 let ref_ = Ref::Doi(Doi("10.1007/BF01340294".to_string()));
4603 let outcome = metadata_only_to_store(&ref_, &profile, &ctx, &store)
4604 .await
4605 .expect("metadata_only_to_store ok");
4606 assert_eq!(
4607 outcome.metadata_quality,
4608 vec!["replacement_char:title".to_string()]
4609 );
4610 assert!(outcome.repaired_fields.is_empty());
4611 let tomls = metadata_dir_tomls(&store_root);
4612 let body = std::fs::read_to_string(&tomls[0]).expect("read metadata toml");
4613 assert!(
4614 body.contains('\u{FFFD}'),
4615 "nothing enabled, so nothing repaired: {body}"
4616 );
4617 std::env::remove_var("DOIGET_CROSSREF_BASE");
4618 }
4619
4620 #[test]
4621 fn short_container_title_is_read_cleaned_and_absent_when_empty_611() {
4622 let f = extract_crossref_fields(
4623 &serde_json::json!({"short-container-title": ["Phys. Rev. <i>B</i>"]}),
4624 );
4625 assert_eq!(f.short_venue.as_deref(), Some("Phys. Rev. B"));
4626 for empty in [
4627 serde_json::json!({"short-container-title": []}),
4628 serde_json::json!({"short-container-title": [""]}),
4629 serde_json::json!({}),
4630 ] {
4631 assert!(
4632 extract_crossref_fields(&empty).short_venue.is_none(),
4633 "{empty}"
4634 );
4635 }
4636 }
4637
4638 #[tokio::test]
4641 #[serial_test::serial]
4642 async fn the_fetch_path_stores_the_crossref_abbreviation_611() {
4643 let body = serde_json::json!({"status": "ok", "message": {
4644 "title": ["Density-matrix algorithms"],
4645 "container-title": ["Physical Review B"],
4646 "short-container-title": ["Phys. Rev. B"]
4647 }})
4648 .to_string();
4649 let (_server, ctx, store, store_root, _td) = md139_harness_with(&body).await;
4650 let profile = CapabilityProfile::from_env().expect("clean env");
4651 let ref_ = Ref::Doi(Doi("10.1103/PhysRevB.48.10345".to_string()));
4652 let _ = fetch_paper(&ref_, &profile, &ctx, &store, &store_root).await;
4653 let toml = std::fs::read_to_string(
4654 store_root.join(".metadata/doi_10.1103_PhysRevB.48.10345.toml"),
4655 )
4656 .expect("the fetch wrote metadata");
4657 assert!(toml.contains("short_venue = \"Phys. Rev. B\""), "{toml}");
4658 std::env::remove_var("DOIGET_CROSSREF_BASE");
4659 }
4660
4661 #[tokio::test]
4670 #[serial_test::serial]
4671 async fn a_recent_retry_after_answer_is_a_timed_wait_507() {
4672 let (_s, ctx, store, store_root, _td) = md139_harness_with("{}").await;
4673 let profile = CapabilityProfile::from_env().expect("profile");
4674 let ref_ = Ref::Doi(Doi::parse("10.1234/busy").expect("doi"));
4675 ctx.log
4676 .repeat()
4677 .observe("10.1234/busy", Some(crate::ErrorCode::NetworkError));
4678 let got = fetch_paper_with(
4679 &ref_,
4680 &profile,
4681 &ctx,
4682 &store,
4683 &store_root,
4684 FetchOptions::default(),
4685 )
4686 .await;
4687 match got {
4688 Err(FetchError::Replayed {
4689 code,
4690 retry_after_secs: Some(secs),
4691 ..
4692 }) => {
4693 assert_eq!(code, crate::ErrorCode::RateLimited);
4694 assert!((1..=30).contains(&secs), "{secs}");
4695 }
4696 other => panic!("expected a timed wait, got {other:?}"),
4697 }
4698 }
4699
4700 #[tokio::test]
4701 #[serial_test::serial]
4702 async fn a_terminal_answer_is_replayed_and_force_asks_again_507() {
4703 use wiremock::matchers::method;
4704 use wiremock::{Mock, MockServer, ResponseTemplate};
4705 let server = MockServer::start().await;
4706 Mock::given(method("GET"))
4707 .respond_with(ResponseTemplate::new(404))
4708 .mount(&server)
4709 .await;
4710 let (_s, ctx, store, store_root, _td) = md139_harness_with("{}").await;
4711 std::env::set_var("DOIGET_CROSSREF_BASE", server.uri());
4713 let host = server.address().to_string();
4714 let ctx = FetchContext {
4715 http: std::sync::Arc::new(crate::http::HttpClient::new_for_tests_allow_http_multi(&[
4716 ("crossref", host.as_str()),
4717 ("unpaywall", host.as_str()),
4718 ])),
4719 ..ctx
4720 };
4721 std::env::set_var("DOIGET_UNPAYWALL_BASE", server.uri());
4722 let profile = CapabilityProfile::from_env().expect("profile");
4723 let ref_ = Ref::Doi(Doi::parse("10.1234/nowhere").expect("doi"));
4724 let requests = || async { server.received_requests().await.unwrap_or_default().len() };
4725
4726 let first = fetch_paper(&ref_, &profile, &ctx, &store, &store_root).await;
4727 assert_eq!(
4728 first.as_ref().err().map(crate::ErrorCode::from),
4729 Some(crate::ErrorCode::NotFound),
4730 "{first:?}"
4731 );
4732 ctx.log
4734 .append(RowInput {
4735 event: LogEvent::SessionEnd,
4736 result: LogResult::Err,
4737 capability: Capability::Oa,
4738 ref_: Some("10.1234/nowhere"),
4739 source: None,
4740 error_code: Some("NOT_FOUND"),
4741 size_bytes: None,
4742 license: None,
4743 store_path: None,
4744 canonical_digest: None,
4745 })
4746 .expect("bookend");
4747 let sent = requests().await;
4748
4749 let second = fetch_paper(&ref_, &profile, &ctx, &store, &store_root).await;
4750 match &second {
4751 Err(e @ FetchError::Replayed { code, .. }) => {
4752 assert_eq!(*code, crate::ErrorCode::NotFound);
4753 assert_eq!(crate::ErrorCode::from(e), crate::ErrorCode::NotFound);
4754 assert!(e.to_string().contains("--refetch"), "{e}");
4755 }
4756 other => panic!("expected a replay, got {other:?}"),
4757 }
4758 assert_eq!(
4759 requests().await,
4760 sent,
4761 "a replay must not touch the network"
4762 );
4763
4764 let dup = batch_fetch(
4765 &[ref_.clone(), ref_.clone()],
4766 &profile,
4767 &ctx,
4768 &store,
4769 &store_root,
4770 )
4771 .await
4772 .expect("batch");
4773 assert!(dup
4774 .results
4775 .iter()
4776 .all(|r| matches!(r.outcome, Err(FetchError::Replayed { .. }))));
4777 assert_eq!(requests().await, sent, "nor may a batch of repeats");
4778
4779 let forced = fetch_paper_with(
4780 &ref_,
4781 &profile,
4782 &ctx,
4783 &store,
4784 &store_root,
4785 FetchOptions::default().with_force(true),
4786 )
4787 .await;
4788 assert!(
4789 matches!(&forced, Err(e) if !matches!(e, FetchError::Replayed { .. })),
4790 "force must reach the network, not replay: {forced:?}"
4791 );
4792 assert!(requests().await > sent, "force asks the network");
4793 let log = std::fs::read_to_string(ctx.log.path()).expect("log");
4794 assert!(
4795 log.lines()
4796 .any(|l| l.contains("\"event\":\"repeat_forced\"")
4797 && l.contains("10.1234/nowhere")
4798 && l.contains("NOT_FOUND")),
4799 "the override is recorded"
4800 );
4801 std::env::remove_var("DOIGET_CROSSREF_BASE");
4802 std::env::remove_var("DOIGET_UNPAYWALL_BASE");
4803 }
4804
4805 #[tokio::test]
4806 #[serial_test::serial]
4807 async fn resolve_only_and_pure_metadata_only_write_nothing_139() {
4808 let (_server, ctx, _store, store_root, _td) = md139_harness().await;
4809 let profile = CapabilityProfile::from_env().expect("clean env");
4810 let ref_ = Ref::Doi(Doi("10.1234/example".to_string()));
4811
4812 let r = resolve_only(&ref_, &profile, &ctx)
4814 .await
4815 .expect("resolve_only ok");
4816 assert_eq!(r.source, "crossref");
4817 assert!(
4818 metadata_dir_tomls(&store_root).is_empty(),
4819 "resolve_only MUST NOT write a metadata TOML (docs/MCP_TOOLS.md §1; #139)"
4820 );
4821
4822 let m = metadata_only(&ref_, &profile, &ctx)
4825 .await
4826 .expect("metadata_only ok");
4827 assert_eq!(m.source, "crossref");
4828 assert!(
4829 metadata_dir_tomls(&store_root).is_empty(),
4830 "pure metadata_only MUST NOT write to the store (#139)"
4831 );
4832
4833 std::env::remove_var("DOIGET_CROSSREF_BASE");
4834 }
4835
4836 #[tokio::test]
4840 #[serial_test::serial]
4841 async fn metadata_only_to_store_arxiv_writes_metadata_toml_139() {
4842 use crate::http::HttpClient;
4843 use crate::provenance::ProvenanceLog;
4844 use crate::rate_limiter::RateLimiter;
4845 use crate::store::FsStore;
4846 use crate::RateLimits;
4847 use std::sync::Arc;
4848 use wiremock::matchers::method;
4849 use wiremock::{Mock, MockServer, ResponseTemplate};
4850
4851 let atom = r#"<?xml version="1.0" encoding="UTF-8"?>
4852<feed xmlns="http://www.w3.org/2005/Atom">
4853 <entry>
4854 <id>http://arxiv.org/abs/2401.12345v1</id>
4855 <published>2024-01-15T00:00:00Z</published>
4856 <title>Example arXiv Paper Title</title>
4857 <summary>Example abstract.</summary>
4858 <author><name>Jane Doe</name></author>
4859 <category term="cs.LG" scheme="http://arxiv.org/schemas/atom"/>
4860 </entry>
4861</feed>"#;
4862 let server = MockServer::start().await;
4863 Mock::given(method("GET"))
4864 .respond_with(ResponseTemplate::new(200).set_body_string(atom))
4865 .mount(&server)
4866 .await;
4867 std::env::set_var("DOIGET_ARXIV_BASE", server.uri());
4868 let host = server
4869 .uri()
4870 .parse::<url::Url>()
4871 .expect("uri")
4872 .host_str()
4873 .expect("host")
4874 .to_string();
4875
4876 let td = tempfile::TempDir::new().expect("tempdir");
4877 let base = Utf8Path::from_path(td.path()).expect("utf-8");
4878 let store_root = base.join("papers");
4879 let ctx = FetchContext {
4880 http: Arc::new(HttpClient::new_for_tests_allow_http("arxiv", &host)),
4881 rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
4882 log: Arc::new(
4883 ProvenanceLog::open(base.join("log.jsonl"), "01J0000000000000000000TEST".into())
4884 .expect("provenance log"),
4885 ),
4886 session_id: "01J0000000000000000000TEST".into(),
4887 cache_root: None,
4888 };
4889 let store = FsStore::new(store_root.clone()).expect("fs store");
4890 let profile = CapabilityProfile::from_env().expect("clean env");
4891 let ref_ = Ref::Arxiv(crate::ArxivId::parse("2401.12345").expect("arxiv id"));
4892
4893 let outcome = metadata_only_to_store(&ref_, &profile, &ctx, &store)
4894 .await
4895 .expect("metadata_only_to_store (arxiv) ok");
4896 assert_eq!(outcome.source, "arxiv");
4897
4898 let tomls = metadata_dir_tomls(&store_root);
4899 assert_eq!(
4900 tomls.len(),
4901 1,
4902 "arXiv metadata-only must write one TOML; got {tomls:?}"
4903 );
4904 let meta: crate::store::Metadata =
4905 toml::from_str(&std::fs::read_to_string(&tomls[0]).expect("read")).expect("parse");
4906 assert_eq!(meta.title, "Example arXiv Paper Title");
4907 assert_eq!(
4908 meta.arxiv_id.as_ref().map(|a| a.as_str()),
4909 Some("2401.12345")
4910 );
4911 assert!(meta.doi.is_none(), "arXiv entry has no DOI");
4912 let ext = meta.doiget.expect("[doiget] table");
4913 assert_eq!(ext.source, "arxiv");
4914 assert_eq!(ext.license, "arxiv-default");
4915
4916 std::env::remove_var("DOIGET_ARXIV_BASE");
4917 }
4918
4919 #[test]
4922 fn extract_metadata_title_handles_string_array_missing_blank() {
4923 use serde_json::json;
4924 assert_eq!(
4926 extract_metadata_title(&json!({"title": "Hello"})),
4927 Some("Hello".to_string())
4928 );
4929 assert_eq!(
4931 extract_metadata_title(&json!({"title": ["Real Title"]})),
4932 Some("Real Title".to_string())
4933 );
4934 assert_eq!(extract_metadata_title(&json!({"x": 1})), None);
4936 assert_eq!(extract_metadata_title(&json!({"title": " "})), None);
4938 assert_eq!(extract_metadata_title(&json!({"title": []})), None);
4940 assert_eq!(
4944 extract_metadata_title(&json!({"title": [" ", "Real Title"]})),
4945 Some("Real Title".to_string())
4946 );
4947 assert_eq!(extract_metadata_title(&json!({"title": [" ", ""]})), None);
4949 }
4950
4951 #[test]
4952 fn extract_metadata_authors_handles_each_resolver_shape() {
4953 use serde_json::json;
4954 assert_eq!(
4956 extract_metadata_authors(&json!({"authors": ["Jane Doe", "John Roe"]})),
4957 vec!["Jane Doe".to_string(), "John Roe".to_string()]
4958 );
4959 assert_eq!(
4961 extract_metadata_authors(&json!({"author": [{"given": "Ada", "family": "Lovelace"}]})),
4962 vec!["Ada Lovelace".to_string()]
4963 );
4964 assert_eq!(
4966 extract_metadata_authors(&json!({"author": [{"family": "Onsager"}]})),
4967 vec!["Onsager".to_string()]
4968 );
4969 assert_eq!(
4971 extract_metadata_authors(&json!({"author": [{"name": "K. Wilson"}]})),
4972 vec!["K. Wilson".to_string()]
4973 );
4974 assert_eq!(
4976 extract_metadata_authors(&json!({"z_authors": [{"given": "L", "family": "Kadanoff"}]})),
4977 vec!["L Kadanoff".to_string()]
4978 );
4979 assert!(extract_metadata_authors(&json!({"x": 1})).is_empty());
4981 assert!(extract_metadata_authors(&json!({"authors": []})).is_empty());
4982 }
4983 #[test]
4987 fn widening_env_is_deduped_and_in_chain_order() {
4988 let attempts = vec![
4989 SourceAttempt::new("crossref", AttemptOutcome::NoRecord),
4990 SourceAttempt::new(
4991 "hal",
4992 AttemptOutcome::Disabled {
4993 env: &["DOIGET_ENABLE_HAL"],
4994 },
4995 ),
4996 SourceAttempt::new(
4997 "tdm-aps",
4998 AttemptOutcome::Disabled {
4999 env: &["DOIGET_KEY_APS", "DOIGET_AGREE_TDM_APS"],
5000 },
5001 ),
5002 SourceAttempt::new(
5004 "hal-again",
5005 AttemptOutcome::Disabled {
5006 env: &["DOIGET_ENABLE_HAL"],
5007 },
5008 ),
5009 ];
5010 assert_eq!(
5011 widening_env(&attempts),
5012 vec![
5013 "DOIGET_ENABLE_HAL",
5014 "DOIGET_KEY_APS",
5015 "DOIGET_AGREE_TDM_APS"
5016 ],
5017 "chain order, de-duplicated, and a consulted source contributes nothing"
5018 );
5019 assert!(
5021 widening_env(&[SourceAttempt::new("crossref", AttemptOutcome::NoRecord)]).is_empty()
5022 );
5023 assert!(widening_env(&[]).is_empty());
5024 }
5025}
5026
5027#[derive(Debug, Clone, PartialEq, Eq)]
5042#[non_exhaustive]
5043pub enum AttemptOutcome {
5044 Disabled {
5047 env: &'static [&'static str],
5056 },
5057 NotApplicable,
5060 WrongPublisher {
5068 detail: String,
5070 },
5071 NotNeeded,
5074 NoRecord,
5076 NotOpenAccess {
5079 detail: String,
5081 },
5082 Denied {
5093 denial: DenialContext,
5095 },
5096 Failed {
5098 detail: String,
5100 },
5101 Resolved,
5103}
5104
5105impl AttemptOutcome {
5106 #[must_use]
5113 pub fn was_consulted(&self) -> bool {
5114 matches!(
5115 self,
5116 Self::NoRecord
5117 | Self::NotOpenAccess { .. }
5118 | Self::Denied { .. }
5119 | Self::Failed { .. }
5120 | Self::Resolved
5121 )
5122 }
5123
5124 #[must_use]
5135 pub fn wire(&self) -> &'static str {
5136 match self {
5137 Self::Disabled { .. } => "not_consulted_disabled",
5138 Self::NotApplicable => "not_consulted_not_applicable",
5139 Self::WrongPublisher { .. } => "not_consulted_wrong_publisher",
5140 Self::NotNeeded => "not_consulted_not_needed",
5141 Self::NoRecord => "consulted_no_record",
5142 Self::NotOpenAccess { .. } => "consulted_not_open_access",
5143 Self::Denied { .. } => "consulted_denied",
5144 Self::Failed { .. } => "consulted_failed",
5145 Self::Resolved => "consulted_resolved",
5146 }
5147 }
5148
5149 #[must_use]
5155 pub fn detail(&self) -> Option<&str> {
5156 match self {
5157 Self::Disabled { .. } | Self::Denied { .. } => None,
5162 Self::WrongPublisher { detail }
5163 | Self::NotOpenAccess { detail }
5164 | Self::Failed { detail } => Some(detail),
5165 _ => None,
5166 }
5167 }
5168
5169 #[must_use]
5172 pub fn required_env(&self) -> Option<&'static [&'static str]> {
5173 match self {
5174 Self::Disabled { env } => Some(env),
5175 _ => None,
5176 }
5177 }
5178
5179 #[must_use]
5182 pub fn denial(&self) -> Option<&DenialContext> {
5183 match self {
5184 Self::Denied { denial } => Some(denial),
5185 _ => None,
5186 }
5187 }
5188
5189 #[must_use]
5192 pub fn render(&self) -> String {
5193 match self {
5194 Self::Disabled { env } => {
5195 format!("not consulted (set {} to enable)", env.join(" + "))
5196 }
5197 Self::NotApplicable => "not consulted (cannot serve this ref kind)".to_string(),
5198 Self::WrongPublisher { detail } => format!("not consulted ({detail})"),
5199 Self::NotNeeded => "not consulted (an earlier source answered)".to_string(),
5200 Self::NoRecord => "consulted: no record".to_string(),
5201 Self::NotOpenAccess { detail } => {
5202 format!("consulted: found, not open access ({detail})")
5203 }
5204 Self::Denied { denial } => match &denial.attempted {
5208 Some(a) => format!("consulted: refused ({:?}, {a})", denial.reason),
5209 None => format!("consulted: refused ({:?})", denial.reason),
5210 },
5211 Self::Failed { detail } => format!("consulted: failed ({detail})"),
5212 Self::Resolved => "consulted: resolved".to_string(),
5213 }
5214 }
5215}
5216
5217#[derive(Debug, Clone, PartialEq, Eq)]
5219#[non_exhaustive]
5220pub struct SourceAttempt {
5221 pub source: &'static str,
5223 pub outcome: AttemptOutcome,
5225}
5226
5227impl SourceAttempt {
5228 #[must_use]
5230 pub fn new(source: &'static str, outcome: AttemptOutcome) -> Self {
5231 Self { source, outcome }
5232 }
5233}
5234
5235#[must_use]
5248pub fn attempts_to_value(attempts: &[SourceAttempt]) -> serde_json::Value {
5249 serde_json::Value::Array(
5250 attempts
5251 .iter()
5252 .map(|a| {
5253 let mut o = serde_json::Map::new();
5254 o.insert("source".into(), serde_json::json!(a.source));
5255 o.insert("outcome".into(), serde_json::json!(a.outcome.wire()));
5256 if let Some(d) = a.outcome.detail() {
5257 o.insert("detail".into(), serde_json::json!(d));
5258 }
5259 if let Some(env) = a.outcome.required_env() {
5260 o.insert("detail".into(), serde_json::json!(env.join(" + ")));
5264 o.insert("required_env".into(), serde_json::json!(env));
5265 }
5266 if let Some(dc) = a.outcome.denial() {
5267 o.insert("detail".into(), serde_json::json!(a.outcome.render()));
5268 o.insert("denial_context".into(), serde_json::json!(dc));
5269 let rem = crate::remediation::for_denial(dc);
5270 if !rem.is_empty() {
5271 o.insert("remediation".into(), serde_json::json!(rem));
5272 }
5273 }
5274 o.insert(
5275 "consulted".into(),
5276 serde_json::json!(a.outcome.was_consulted()),
5277 );
5278 serde_json::Value::Object(o)
5279 })
5280 .collect(),
5281 )
5282}
5283
5284#[must_use]
5289pub fn render_attempts(attempts: &[SourceAttempt]) -> String {
5290 attempts
5291 .iter()
5292 .map(|a| format!(" {:<12} {}", a.source, a.outcome.render()))
5293 .collect::<Vec<_>>()
5294 .join("\n")
5295}
5296
5297#[must_use]
5308pub fn widening_env(attempts: &[SourceAttempt]) -> Vec<&'static str> {
5309 let mut seen = Vec::new();
5310 for a in attempts {
5311 for var in a.outcome.required_env().unwrap_or_default() {
5312 if !seen.contains(var) {
5313 seen.push(*var);
5314 }
5315 }
5316 }
5317 seen
5318}
5319
5320#[must_use]
5325pub fn nothing_was_consulted(attempts: &[SourceAttempt]) -> bool {
5326 !attempts.is_empty() && attempts.iter().all(|a| !a.outcome.was_consulted())
5327}
5328
5329#[cfg(any(
5335 feature = "metadata",
5336 feature = "tdm-elsevier",
5337 feature = "tdm-aps",
5338 feature = "tdm-springer",
5339 feature = "tdm-ieee"
5340))]
5341fn classify_attempt(e: &FetchError) -> AttemptOutcome {
5342 match e {
5343 FetchError::NotFound { .. } => AttemptOutcome::NoRecord,
5344 FetchError::NotRetrievable { detail, .. } => AttemptOutcome::NotOpenAccess {
5351 detail: detail.clone(),
5352 },
5353 other => match Option::<DenialContext>::from(other) {
5367 Some(denial) if denial.reason != crate::DenialReason::CapabilityNotGranted => {
5368 AttemptOutcome::Denied { denial }
5369 }
5370 _ => AttemptOutcome::Failed {
5371 detail: other.to_string(),
5372 },
5373 },
5374 }
5375}
5376
5377#[cfg(all(
5383 test,
5384 any(
5385 feature = "metadata",
5386 feature = "tdm-elsevier",
5387 feature = "tdm-aps",
5388 feature = "tdm-springer",
5389 feature = "tdm-ieee"
5390 )
5391))]
5392#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
5393mod attempt_denial_tests {
5394 use super::*;
5395
5396 use crate::http::HttpError;
5397 use crate::DenialReason;
5398
5399 fn redirect_denial() -> FetchError {
5400 FetchError::Http(HttpError::RedirectDenied {
5401 source_key: "hal".to_string(),
5402 host: "cdn.example.org".to_string(),
5403 expected_hosts: vec!["hal.science".to_string()],
5404 })
5405 }
5406
5407 #[test]
5412 fn a_policy_refusal_keeps_its_denial_context() {
5413 let outcome = classify_attempt(&redirect_denial());
5414 let denial = outcome
5415 .denial()
5416 .expect("a redirect denial must survive classification");
5417 assert_eq!(denial.reason, DenialReason::RedirectNotInAllowlist);
5418 assert_eq!(denial.attempted.as_deref(), Some("cdn.example.org"));
5419 assert_eq!(outcome.wire(), "consulted_denied");
5420 assert!(
5421 outcome.was_consulted(),
5422 "a refusal means a request went out"
5423 );
5424 }
5425
5426 #[test]
5430 fn a_denied_row_carries_a_remediation_on_the_wire() {
5431 let attempts = vec![SourceAttempt::new(
5432 "hal",
5433 classify_attempt(&redirect_denial()),
5434 )];
5435 let v = attempts_to_value(&attempts);
5436 let row = &v[0];
5437
5438 assert_eq!(row["outcome"], serde_json::json!("consulted_denied"));
5439 assert_eq!(
5440 row["denial_context"]["reason"],
5441 serde_json::json!("redirect_not_in_allowlist"),
5442 "row: {row}"
5443 );
5444 let rem = row["remediation"]
5445 .as_array()
5446 .unwrap_or_else(|| panic!("a redirect denial has a config channel; row: {row}"));
5447 assert!(!rem.is_empty());
5448 assert!(
5449 row["detail"].is_string(),
5450 "`detail` must stay populated for a #459-era consumer; row: {row}"
5451 );
5452 }
5453
5454 #[test]
5459 fn an_ungranted_capability_is_not_reported_as_consulted_and_denied() {
5460 let outcome = classify_attempt(&FetchError::NotEligible {
5461 source_key: "tdm-aps".into(),
5462 });
5463 assert!(
5464 outcome.denial().is_none(),
5465 "got {outcome:?}: this never reached the network"
5466 );
5467 assert_eq!(outcome.wire(), "consulted_failed");
5468 }
5469
5470 #[test]
5474 fn the_accessors_narrow_rather_than_generalise() {
5475 let failed = AttemptOutcome::Failed {
5476 detail: "connection reset".to_string(),
5477 };
5478 assert!(failed.denial().is_none());
5479 assert!(failed.required_env().is_none());
5480 assert!(failed.detail().is_some());
5481
5482 let disabled = AttemptOutcome::Disabled {
5483 env: &["DOIGET_ENABLE_HAL"],
5484 };
5485 assert!(disabled.denial().is_none());
5486 assert!(
5487 disabled.detail().is_none(),
5488 "`Disabled` carries structure; the joined string is built at the wire"
5489 );
5490 assert!(!disabled.was_consulted());
5491 assert_eq!(
5492 disabled.render(),
5493 "not consulted (set DOIGET_ENABLE_HAL to enable)"
5494 );
5495 }
5496
5497 #[test]
5500 fn a_denial_without_an_attempted_host_still_renders() {
5501 let outcome = AttemptOutcome::Denied {
5502 denial: DenialContext {
5503 reason: DenialReason::SizeCapExceeded,
5504 source: Some("core".to_string()),
5505 attempted: None,
5506 expected: None,
5507 hop_index: None,
5508 cap: None,
5509 actual: None,
5510 },
5511 };
5512 assert_eq!(outcome.render(), "consulted: refused (SizeCapExceeded)");
5513
5514 let v = attempts_to_value(&[SourceAttempt::new("core", outcome)]);
5518 assert!(v[0].get("remediation").is_none(), "row: {}", v[0]);
5519 assert!(v[0].get("denial_context").is_some(), "row: {}", v[0]);
5520 }
5521
5522 #[test]
5526 fn a_disabled_row_lists_its_variables_instead_of_joining_them() {
5527 let attempts = vec![SourceAttempt::new(
5528 "tdm-aps",
5529 AttemptOutcome::Disabled {
5530 env: &["DOIGET_KEY_APS", "DOIGET_AGREE_TDM_APS"],
5531 },
5532 )];
5533 let v = attempts_to_value(&attempts);
5534 let row = &v[0];
5535
5536 assert_eq!(
5537 row["required_env"],
5538 serde_json::json!(["DOIGET_KEY_APS", "DOIGET_AGREE_TDM_APS"]),
5539 "row: {row}"
5540 );
5541 assert_eq!(
5543 row["detail"],
5544 serde_json::json!("DOIGET_KEY_APS + DOIGET_AGREE_TDM_APS"),
5545 "row: {row}"
5546 );
5547 }
5548}
5549
5550#[cfg(feature = "metadata")]
5562async fn resolve_optional_chain(
5563 ref_: &Ref,
5564 profile: &CapabilityProfile,
5565 ctx: &FetchContext,
5566 crossref_answered: bool,
5567 extracted: &mut CrossrefFields,
5568 attempts: &mut Vec<SourceAttempt>,
5569) -> Option<(&'static str, Value)> {
5570 let datacite = optional_base("DOIGET_DATACITE_BASE").map_or_else(
5580 crate::sources::datacite::DataCiteSource::new,
5581 crate::sources::datacite::DataCiteSource::with_base,
5582 );
5583 let epmc = optional_base("DOIGET_EUROPE_PMC_BASE").map_or_else(
5584 crate::sources::europepmc::EuropePmcSource::new,
5585 crate::sources::europepmc::EuropePmcSource::with_base,
5586 );
5587 let openaire = optional_base("DOIGET_OPENAIRE_BASE").map_or_else(
5588 crate::sources::openaire::OpenAireSource::new,
5589 crate::sources::openaire::OpenAireSource::with_base,
5590 );
5591 let hal = optional_base("DOIGET_HAL_BASE").map_or_else(
5592 crate::sources::hal::HalSource::new,
5593 crate::sources::hal::HalSource::with_base,
5594 );
5595 let core = optional_base("DOIGET_CORE_BASE").map_or_else(
5596 crate::sources::core_oa::CoreSource::new,
5597 crate::sources::core_oa::CoreSource::with_base,
5598 );
5599 let openalex_contact = resolve_contact_email();
5605 let openalex = match optional_base("DOIGET_OPENALEX_BASE") {
5606 Some(base) => crate::sources::openalex::OpenalexSource::with_base(base, openalex_contact),
5607 None => crate::sources::openalex::OpenalexSource::new(openalex_contact),
5608 };
5609
5610 let chain: Vec<(
5619 &'static str,
5620 &'static [&'static str],
5621 &dyn crate::source::Source,
5622 )> = vec![
5623 ("datacite", &["DOIGET_ENABLE_DATACITE"], &datacite),
5624 ("europe-pmc", &["DOIGET_ENABLE_EUROPE_PMC"], &epmc),
5625 ("openaire", &["DOIGET_ENABLE_OPENAIRE"], &openaire),
5626 ("hal", &["DOIGET_ENABLE_HAL"], &hal),
5627 ("core", &["DOIGET_ENABLE_CORE"], &core),
5628 ("openalex", &["DOIGET_ENABLE_OPENALEX"], &openalex),
5629 ];
5630
5631 let mut resolved: Option<(&'static str, Value)> = None;
5632
5633 for (name, env, src) in chain {
5634 debug_assert_eq!(name, src.name(), "chain name must match Source::name");
5635
5636 if crossref_answered || resolved.is_some() {
5638 attempts.push(SourceAttempt::new(name, AttemptOutcome::NotNeeded));
5639 continue;
5640 }
5641 if !src.can_serve(profile, ref_) {
5645 let outcome = if matches!(ref_, Ref::Doi(_)) {
5646 AttemptOutcome::Disabled { env }
5647 } else {
5648 AttemptOutcome::NotApplicable
5649 };
5650 attempts.push(SourceAttempt::new(name, outcome));
5651 continue;
5652 }
5653
5654 match src.fetch(ref_, profile, ctx).await {
5655 Ok(r) => {
5656 if let Some(meta) = r.metadata_json.as_ref() {
5657 *extracted = extract_optional_fields(name, meta);
5658 }
5659 attempts.push(SourceAttempt::new(name, AttemptOutcome::Resolved));
5660 resolved = r.metadata_json.map(|m| (name, m));
5661 }
5662 Err(e) => {
5663 tracing::debug!(source = name, error = %e, "optional source did not resolve");
5664 attempts.push(SourceAttempt::new(name, classify_attempt(&e)));
5665 }
5666 }
5667 }
5668 resolved
5669}
5670
5671#[cfg(any(
5698 feature = "tdm-elsevier",
5699 feature = "tdm-aps",
5700 feature = "tdm-springer",
5701 feature = "tdm-ieee"
5702))]
5703#[allow(clippy::vec_init_then_push)]
5707async fn resolve_tdm_chain(
5708 ref_: &Ref,
5709 profile: &CapabilityProfile,
5710 ctx: &FetchContext,
5711 crossref_answered: bool,
5712 attempts: &mut Vec<SourceAttempt>,
5713) -> Option<Value> {
5714 struct Entry<'a> {
5715 name: &'static str,
5716 enable_hint: &'static [&'static str],
5718 prefixes: &'static [&'static str],
5720 publisher: &'static str,
5722 src: &'a dyn crate::source::Source,
5723 }
5724
5725 #[cfg(feature = "tdm-aps")]
5729 let aps = optional_base("DOIGET_APS_BASE").map_or_else(
5730 crate::sources::tdm_aps::TdmApsSource::new,
5731 crate::sources::tdm_aps::TdmApsSource::with_base,
5732 );
5733 #[cfg(feature = "tdm-elsevier")]
5734 let elsevier = optional_base("DOIGET_ELSEVIER_BASE").map_or_else(
5735 crate::sources::tdm_elsevier::TdmElsevierSource::new,
5736 crate::sources::tdm_elsevier::TdmElsevierSource::with_base,
5737 );
5738 #[cfg(feature = "tdm-springer")]
5739 let springer = optional_base("DOIGET_SPRINGER_BASE").map_or_else(
5740 crate::sources::tdm_springer::TdmSpringerSource::new,
5741 crate::sources::tdm_springer::TdmSpringerSource::with_base,
5742 );
5743 #[cfg(feature = "tdm-ieee")]
5744 let ieee = optional_base("DOIGET_IEEE_BASE").map_or_else(
5745 crate::sources::tdm_ieee::TdmIeeeSource::new,
5746 crate::sources::tdm_ieee::TdmIeeeSource::with_base,
5747 );
5748
5749 #[allow(unused_mut)]
5753 let mut chain: Vec<Entry<'_>> = Vec::new();
5754 #[cfg(feature = "tdm-aps")]
5755 chain.push(Entry {
5756 name: "tdm-aps",
5757 enable_hint: &["DOIGET_KEY_APS", "DOIGET_AGREE_TDM_APS"],
5758 prefixes: crate::sources::tdm_aps::PUBLISHER_PREFIXES,
5759 publisher: "American Physical Society (APS)",
5760 src: &aps,
5761 });
5762 #[cfg(feature = "tdm-elsevier")]
5763 chain.push(Entry {
5764 name: "tdm-elsevier",
5765 enable_hint: &["DOIGET_KEY_ELSEVIER", "DOIGET_AGREE_TDM_ELSEVIER"],
5766 prefixes: crate::sources::tdm_elsevier::PUBLISHER_PREFIXES,
5767 publisher: "Elsevier BV",
5768 src: &elsevier,
5769 });
5770 #[cfg(feature = "tdm-springer")]
5771 chain.push(Entry {
5772 name: "tdm-springer",
5773 enable_hint: &["DOIGET_KEY_SPRINGER", "DOIGET_AGREE_TDM_SPRINGER"],
5774 prefixes: crate::sources::tdm_springer::PUBLISHER_PREFIXES,
5775 publisher: "Springer Nature",
5776 src: &springer,
5777 });
5778 #[cfg(feature = "tdm-ieee")]
5779 chain.push(Entry {
5780 name: "tdm-ieee",
5781 enable_hint: &["DOIGET_KEY_IEEE", "DOIGET_AGREE_TDM_IEEE"],
5782 prefixes: crate::sources::tdm_ieee::PUBLISHER_PREFIXES,
5783 publisher: "IEEE",
5784 src: &ieee,
5785 });
5786
5787 let mut resolved: Option<Value> = None;
5788
5789 for e in chain {
5790 debug_assert_eq!(e.name, e.src.name(), "chain name must match Source::name");
5791
5792 if crossref_answered || resolved.is_some() {
5793 attempts.push(SourceAttempt::new(e.name, AttemptOutcome::NotNeeded));
5794 continue;
5795 }
5796 let Ref::Doi(doi) = ref_ else {
5797 attempts.push(SourceAttempt::new(e.name, AttemptOutcome::NotApplicable));
5798 continue;
5799 };
5800 if !e.prefixes.contains(&doi.prefix()) {
5805 attempts.push(SourceAttempt::new(
5806 e.name,
5807 AttemptOutcome::WrongPublisher {
5808 detail: format!("DOI prefix {} is not {}", doi.prefix(), e.publisher),
5809 },
5810 ));
5811 continue;
5812 }
5813 if !e.src.can_serve(profile, ref_) {
5814 attempts.push(SourceAttempt::new(
5815 e.name,
5816 AttemptOutcome::Disabled { env: e.enable_hint },
5817 ));
5818 continue;
5819 }
5820
5821 match e.src.fetch(ref_, profile, ctx).await {
5822 Ok(r) => {
5823 attempts.push(SourceAttempt::new(e.name, AttemptOutcome::Resolved));
5824 resolved = r.metadata_json;
5825 }
5826 Err(err) => {
5827 tracing::debug!(source = e.name, error = %err, "TDM source did not resolve");
5828 attempts.push(SourceAttempt::new(e.name, classify_attempt(&err)));
5829 }
5830 }
5831 }
5832 resolved
5833}
5834
5835#[cfg(any(
5838 feature = "metadata",
5839 feature = "tdm-elsevier",
5840 feature = "tdm-aps",
5841 feature = "tdm-springer",
5842 feature = "tdm-ieee"
5843))]
5844fn optional_base(env: &str) -> Option<url::Url> {
5845 let raw = std::env::var(env).ok()?;
5846 match url::Url::parse(&raw) {
5847 Ok(u) => Some(u),
5848 Err(e) => {
5849 tracing::warn!(value = %raw, error = %e, env, "base override is not a valid URL; using the default");
5850 None
5851 }
5852 }
5853}
5854
5855#[cfg(feature = "metadata")]
5864fn extract_optional_fields(source: &str, meta: &Value) -> CrossrefFields {
5865 match source {
5866 "datacite" => extract_datacite_fields(meta),
5867 _ => CrossrefFields::default(),
5868 }
5869}
5870
5871#[cfg(all(test, feature = "metadata"))]
5887#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
5888mod chain_tests {
5889 use super::*;
5890
5891 use std::sync::Arc;
5892
5893 use camino::Utf8PathBuf;
5894 use tempfile::TempDir;
5895 use wiremock::matchers::method;
5896 use wiremock::{Mock, MockServer, ResponseTemplate};
5897
5898 use crate::http::HttpClient;
5899 use crate::provenance::ProvenanceLog;
5900 use crate::rate_limiter::RateLimiter;
5901 use crate::{CapabilityProfile, Doi, MetadataAccess, RateLimits, Ref};
5902
5903 struct BaseGuard(Vec<(&'static str, Option<String>)>);
5909 impl BaseGuard {
5910 fn to(uri: &str) -> Self {
5911 const VARS: &[&str] = &[
5912 "DOIGET_DATACITE_BASE",
5913 "DOIGET_EUROPE_PMC_BASE",
5914 "DOIGET_OPENAIRE_BASE",
5915 "DOIGET_HAL_BASE",
5916 "DOIGET_CORE_BASE",
5917 "DOIGET_OPENALEX_BASE",
5918 ];
5919 Self(
5920 VARS.iter()
5921 .map(|v| {
5922 let old = std::env::var(v).ok();
5923 std::env::set_var(v, uri);
5924 (*v, old)
5925 })
5926 .collect(),
5927 )
5928 }
5929 }
5930 impl Drop for BaseGuard {
5931 fn drop(&mut self) {
5932 for (v, old) in &self.0 {
5933 match old {
5934 Some(o) => std::env::set_var(v, o),
5935 None => std::env::remove_var(v),
5936 }
5937 }
5938 }
5939 }
5940
5941 fn ctx_for(host: &str) -> (TempDir, FetchContext) {
5942 let td = TempDir::new().expect("tempdir");
5943 let dir = Utf8PathBuf::try_from(td.path().to_path_buf()).expect("utf-8");
5944 let http = Arc::new(HttpClient::new_for_tests_allow_http_multi(&[
5945 ("datacite", host),
5946 ("europe-pmc", host),
5947 ("openaire", host),
5948 ("hal", host),
5949 ("core", host),
5950 ("openalex", host),
5956 ]));
5957 let session_id = "01J0000000000000000000TEST".to_string();
5958 let log = Arc::new(
5959 ProvenanceLog::open(dir.join("t.jsonl"), session_id.clone()).expect("log opens"),
5960 );
5961 (
5962 td,
5963 FetchContext {
5964 http,
5965 rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
5966 log,
5967 session_id,
5968 cache_root: None,
5969 },
5970 )
5971 }
5972
5973 fn all_off() -> CapabilityProfile {
5974 let mut p = CapabilityProfile::for_tests();
5975 p.metadata = MetadataAccess {
5976 openalex: false,
5977 semantic_scholar: false,
5978 doaj: false,
5979 biorxiv: false,
5980 inspire: false,
5981 ads: false,
5982 datacite: false,
5983 hal: false,
5984 openaire: false,
5985 core: false,
5986 europe_pmc: false,
5987 };
5988 p
5989 }
5990
5991 fn all_on() -> CapabilityProfile {
5992 let mut p = all_off();
5993 p.metadata.datacite = true;
5994 p.metadata.hal = true;
5995 p.metadata.openaire = true;
5996 p.metadata.core = true;
5997 p.metadata.europe_pmc = true;
5998 p.metadata.openalex = true;
5999 p
6000 }
6001
6002 fn outcome<'a>(attempts: &'a [SourceAttempt], name: &str) -> &'a AttemptOutcome {
6003 &attempts
6004 .iter()
6005 .find(|a| a.source == name)
6006 .unwrap_or_else(|| panic!("no attempt recorded for {name}; got {attempts:?}"))
6007 .outcome
6008 }
6009
6010 #[test]
6022 fn every_oa_url_bearing_source_has_a_dispatch_arm() {
6023 let cases: &[(&str, serde_json::Value, &str)] = &[
6027 (
6028 "core",
6029 serde_json::json!({ "downloadUrl": "https://core.example/1.pdf" }),
6030 "https://core.example/1.pdf",
6031 ),
6032 (
6033 "hal",
6034 serde_json::json!({
6035 "openAccess_bool": true,
6036 "fileMain_s": "https://hal.example/2.pdf"
6037 }),
6038 "https://hal.example/2.pdf",
6039 ),
6040 (
6041 "europe-pmc",
6042 serde_json::json!({
6043 "fullTextUrlList": {
6044 "fullTextUrl": [{
6045 "documentStyle": "pdf",
6046 "availabilityCode": "OA",
6047 "url": "https://epmc.example/3.pdf"
6048 }]
6049 }
6050 }),
6051 "https://epmc.example/3.pdf",
6052 ),
6053 (
6054 "openalex",
6055 serde_json::json!({
6056 "locations": [
6057 { "is_oa": true, "pdf_url": "https://repo.example.ac.uk/4.pdf" }
6058 ]
6059 }),
6060 "https://repo.example.ac.uk/4.pdf",
6061 ),
6062 ];
6063
6064 let mut broken: Vec<String> = Vec::new();
6065 for (source, meta, expected) in cases {
6066 let got = optional_source_oa_url(source, meta);
6067 if got != Some(*expected) {
6068 broken.push(format!("{source}: expected {expected:?}, got {got:?}"));
6069 }
6070 }
6071 assert!(
6072 broken.is_empty(),
6073 "these sources are in the optional chain but their document URL never reaches the \
6074 content leg -- a missing or wrong `optional_source_oa_url` arm:\n {}",
6075 broken.join("\n ")
6076 );
6077
6078 assert_eq!(
6081 optional_source_oa_url("datacite", &serde_json::json!({ "downloadUrl": "x" })),
6082 None,
6083 "datacite reports no document URL; it must not fall through to another arm"
6084 );
6085 }
6086
6087 #[tokio::test]
6093 #[serial_test::serial]
6094 async fn every_optional_source_is_actually_reached_by_the_production_chain() {
6095 let server = MockServer::start().await;
6096 let _bases = BaseGuard::to(&server.uri());
6097 Mock::given(method("GET"))
6100 .respond_with(ResponseTemplate::new(200).set_body_string(
6101 r#"{"results":[],"response":{"docs":[]},"resultList":{"result":[]}}"#,
6102 ))
6103 .mount(&server)
6104 .await;
6105
6106 let (_td, ctx) = ctx_for(&server.address().to_string());
6107 let ref_ = Ref::Doi(Doi::parse("10.1234/example").expect("doi"));
6108 let mut fields = CrossrefFields::default();
6109 let mut attempts = Vec::new();
6110
6111 resolve_optional_chain(&ref_, &all_on(), &ctx, false, &mut fields, &mut attempts).await;
6112
6113 let names: Vec<&str> = attempts.iter().map(|a| a.source).collect();
6114 assert_eq!(
6115 names,
6116 vec![
6117 "datacite",
6118 "europe-pmc",
6119 "openaire",
6120 "hal",
6121 "core",
6122 "openalex"
6123 ],
6124 "the trace must list every source, in chain order"
6125 );
6126 for a in &attempts {
6127 assert!(
6128 a.outcome.was_consulted(),
6129 "{} was NOT reached by the production chain: {:?}",
6130 a.source,
6131 a.outcome
6132 );
6133 }
6134 assert_eq!(
6135 server.received_requests().await.expect("recorded").len(),
6136 6,
6137 "each enabled source must issue exactly one request"
6138 );
6139 }
6140
6141 #[tokio::test]
6145 #[serial_test::serial]
6146 async fn flags_off_means_never_consulted_and_says_which_var_to_set() {
6147 let server = MockServer::start().await;
6148 let _bases = BaseGuard::to(&server.uri());
6149 let (_td, ctx) = ctx_for(&server.address().to_string());
6150 let ref_ = Ref::Doi(Doi::parse("10.1234/example").expect("doi"));
6151 let mut fields = CrossrefFields::default();
6152 let mut attempts = Vec::new();
6153
6154 resolve_optional_chain(&ref_, &all_off(), &ctx, false, &mut fields, &mut attempts).await;
6155
6156 assert!(
6157 server
6158 .received_requests()
6159 .await
6160 .expect("recorded")
6161 .is_empty(),
6162 "a disabled chain must make NO request"
6163 );
6164 assert!(
6165 nothing_was_consulted(&attempts),
6166 "the trace must report that nothing was reached"
6167 );
6168 assert_eq!(
6169 outcome(&attempts, "hal"),
6170 &AttemptOutcome::Disabled {
6171 env: &["DOIGET_ENABLE_HAL"]
6172 },
6173 "a disabled source must name the variable that enables it"
6174 );
6175 let rendered = render_attempts(&attempts);
6176 assert!(
6177 rendered.contains("not consulted (set DOIGET_ENABLE_HAL to enable)"),
6178 "rendered trace must be actionable; got:\n{rendered}"
6179 );
6180 }
6181
6182 #[tokio::test]
6186 #[serial_test::serial]
6187 async fn never_consulted_and_consulted_but_empty_render_differently() {
6188 let server = MockServer::start().await;
6190 let _bases = BaseGuard::to(&server.uri());
6191 Mock::given(method("GET"))
6192 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"results":[]}"#))
6193 .mount(&server)
6194 .await;
6195 let (_td, ctx) = ctx_for(&server.address().to_string());
6196 let ref_ = Ref::Doi(Doi::parse("10.1234/example").expect("doi"));
6197 let mut f1 = CrossrefFields::default();
6198 let mut consulted = Vec::new();
6199 let mut on = all_off();
6200 on.metadata.datacite = true;
6201 resolve_optional_chain(&ref_, &on, &ctx, false, &mut f1, &mut consulted).await;
6202
6203 let mut f2 = CrossrefFields::default();
6205 let mut skipped = Vec::new();
6206 resolve_optional_chain(&ref_, &all_off(), &ctx, false, &mut f2, &mut skipped).await;
6207
6208 let a = outcome(&consulted, "datacite");
6209 let b = outcome(&skipped, "datacite");
6210 assert!(a.was_consulted(), "(a) must be consulted, got {a:?}");
6211 assert!(!b.was_consulted(), "(b) must NOT be consulted, got {b:?}");
6212 assert_ne!(
6213 a.render(),
6214 b.render(),
6215 "the two states MUST NOT render identically"
6216 );
6217 assert!(a.render().starts_with("consulted:"), "{}", a.render());
6218 assert!(b.render().starts_with("not consulted"), "{}", b.render());
6219 }
6220
6221 #[tokio::test]
6224 #[serial_test::serial]
6225 async fn a_crossref_hit_skips_the_chain_without_pretending_it_was_disabled() {
6226 let server = MockServer::start().await;
6227 let _bases = BaseGuard::to(&server.uri());
6228 let (_td, ctx) = ctx_for(&server.address().to_string());
6229 let ref_ = Ref::Doi(Doi::parse("10.1234/example").expect("doi"));
6230 let mut fields = CrossrefFields::default();
6231 let mut attempts = Vec::new();
6232
6233 resolve_optional_chain(&ref_, &all_on(), &ctx, true, &mut fields, &mut attempts).await;
6234
6235 assert!(
6236 server
6237 .received_requests()
6238 .await
6239 .expect("recorded")
6240 .is_empty(),
6241 "a Crossref hit must cost no extra requests"
6242 );
6243 for a in &attempts {
6244 assert_eq!(
6245 a.outcome,
6246 AttemptOutcome::NotNeeded,
6247 "{} must be NotNeeded, not Disabled — the flags ARE on",
6248 a.source
6249 );
6250 }
6251 }
6252
6253 #[tokio::test]
6264 #[serial_test::serial]
6265 async fn an_access_refusal_is_recorded_distinctly_from_a_miss() {
6266 let server = MockServer::start().await;
6267 let _bases = BaseGuard::to(&server.uri());
6268 Mock::given(method("GET"))
6269 .respond_with(ResponseTemplate::new(200).set_body_string(
6270 r#"{"resultList":{"result":[{"doi":"10.1234/x","isOpenAccess":"N","inEPMC":"Y"}]}}"#,
6271 ))
6272 .mount(&server)
6273 .await;
6274 let (_td, ctx) = ctx_for(&server.address().to_string());
6275 let ref_ = Ref::Doi(Doi::parse("10.1234/x").expect("doi"));
6276 let mut fields = CrossrefFields::default();
6277 let mut attempts = Vec::new();
6278 let mut on = all_off();
6279 on.metadata.europe_pmc = true;
6280
6281 resolve_optional_chain(&ref_, &on, &ctx, false, &mut fields, &mut attempts).await;
6282
6283 let o = outcome(&attempts, "europe-pmc");
6284 assert!(
6285 matches!(o, AttemptOutcome::NotOpenAccess { .. }),
6286 "a closed record must be NotOpenAccess, not NoRecord/Failed; got {o:?}"
6287 );
6288 assert!(o.was_consulted(), "it WAS reached");
6289 assert!(
6290 o.render().contains("not open access"),
6291 "the reason must survive into the message; got {}",
6292 o.render()
6293 );
6294 }
6295
6296 #[tokio::test]
6305 #[serial_test::serial]
6306 async fn a_closed_subset_record_with_a_free_pdf_resolves_and_carries_its_url() {
6307 let server = MockServer::start().await;
6308 let _bases = BaseGuard::to(&server.uri());
6309 Mock::given(method("GET"))
6310 .respond_with(ResponseTemplate::new(200).set_body_string(
6311 r#"{"resultList":{"result":[{"doi":"10.1098/rspa.2014.0585",
6312 "isOpenAccess":"N","inEPMC":"Y","fullTextUrlList":{"fullTextUrl":[
6313 {"availability":"Free","availabilityCode":"F","documentStyle":"pdf",
6314 "site":"Europe_PMC",
6315 "url":"https://europepmc.org/articles/PMC4277194?pdf=render"}]}}]}}"#,
6316 ))
6317 .mount(&server)
6318 .await;
6319 let (_td, ctx) = ctx_for(&server.address().to_string());
6320 let ref_ = Ref::Doi(Doi::parse("10.1098/rspa.2014.0585").expect("doi"));
6321 let mut fields = CrossrefFields::default();
6322 let mut attempts = Vec::new();
6323 let mut on = all_off();
6324 on.metadata.europe_pmc = true;
6325
6326 let resolved =
6327 resolve_optional_chain(&ref_, &on, &ctx, false, &mut fields, &mut attempts).await;
6328
6329 let (source, record) = resolved.expect("a Free PDF entry must resolve, not refuse");
6330 assert_eq!(source, "europe-pmc");
6331 let row = outcome(&attempts, "europe-pmc");
6332 assert!(
6333 matches!(row, AttemptOutcome::Resolved),
6334 "the row must read as resolved, not as an access refusal; got {row:?}"
6335 );
6336 assert_eq!(
6337 optional_source_oa_url("europe-pmc", &record),
6338 Some("https://europepmc.org/articles/PMC4277194?pdf=render"),
6339 "the URL the oa-publisher leg fetches must survive the chain"
6340 );
6341 }
6342
6343 #[tokio::test]
6346 #[serial_test::serial]
6347 async fn an_arxiv_ref_is_not_applicable_rather_than_disabled() {
6348 let server = MockServer::start().await;
6349 let _bases = BaseGuard::to(&server.uri());
6350 let (_td, ctx) = ctx_for(&server.address().to_string());
6351 let ref_ = Ref::Arxiv(crate::ArxivId::parse("2401.12345").expect("arxiv"));
6352 let mut fields = CrossrefFields::default();
6353 let mut attempts = Vec::new();
6354
6355 resolve_optional_chain(&ref_, &all_on(), &ctx, false, &mut fields, &mut attempts).await;
6356
6357 for a in &attempts {
6358 assert_eq!(
6359 a.outcome,
6360 AttemptOutcome::NotApplicable,
6361 "{} must be NotApplicable for an arXiv ref",
6362 a.source
6363 );
6364 assert!(
6365 !a.outcome.render().contains("set DOIGET_"),
6366 "must not suggest a variable that would not help: {}",
6367 a.outcome.render()
6368 );
6369 }
6370 assert!(server
6371 .received_requests()
6372 .await
6373 .expect("recorded")
6374 .is_empty());
6375 }
6376}
6377
6378#[cfg(all(
6411 test,
6412 any(
6413 feature = "tdm-aps",
6414 feature = "tdm-elsevier",
6415 feature = "tdm-springer",
6416 feature = "tdm-ieee"
6417 )
6418))]
6419#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
6420mod tdm_singleton_reach_tests {
6421 use super::*;
6422
6423 use std::sync::Arc;
6424
6425 use camino::Utf8PathBuf;
6426 use tempfile::TempDir;
6427
6428 use crate::http::HttpClient;
6429 use crate::provenance::ProvenanceLog;
6430 use crate::rate_limiter::RateLimiter;
6431 use crate::{CapabilityProfile, Doi, RateLimits, Ref};
6432
6433 fn ctx() -> (TempDir, FetchContext) {
6434 let td = TempDir::new().expect("tempdir");
6435 let dir = Utf8PathBuf::try_from(td.path().to_path_buf()).expect("utf-8");
6436 let session_id = "01J0000000000000000000SNG".to_string();
6437 let log = Arc::new(
6438 ProvenanceLog::open(dir.join("t.jsonl"), session_id.clone()).expect("log opens"),
6439 );
6440 let c = FetchContext {
6441 http: Arc::new(HttpClient::new_for_tests_allow_http(
6442 "tdm-probe",
6443 "127.0.0.1:1",
6444 )),
6445 rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
6446 log,
6447 session_id,
6448 cache_root: None,
6449 };
6450 (td, c)
6451 }
6452
6453 #[tokio::test]
6458 #[serial_test::serial]
6459 #[allow(clippy::vec_init_then_push)]
6460 async fn every_compiled_publisher_is_in_the_chain() {
6461 let mut cases: Vec<(&str, &str)> = Vec::new();
6464 #[cfg(feature = "tdm-aps")]
6465 cases.push(("tdm-aps", "10.1103/PhysRevX.10.011001"));
6466 #[cfg(feature = "tdm-elsevier")]
6467 cases.push(("tdm-elsevier", "10.1016/j.example.2024.001"));
6468 #[cfg(feature = "tdm-springer")]
6469 cases.push(("tdm-springer", "10.1007/s00220-024-05001-x"));
6470 #[cfg(feature = "tdm-ieee")]
6471 cases.push(("tdm-ieee", "10.1109/TSP.2018.2812747"));
6472 assert!(!cases.is_empty(), "the guard must have checked something");
6473
6474 let (_td, c) = ctx();
6475 let profile = CapabilityProfile::for_tests();
6476
6477 for (name, doi) in cases {
6478 let ref_ = Ref::Doi(Doi::parse(doi).expect("doi"));
6479 let mut attempts = Vec::new();
6480 resolve_tdm_chain(&ref_, &profile, &c, false, &mut attempts).await;
6481 assert!(
6482 attempts.iter().any(|a| a.source == name),
6483 "`{name}` is compiled but absent from the chain for its own DOI {doi}; \
6484 the production path cannot reach it. attempts: {attempts:?}"
6485 );
6486 }
6487 }
6488}
6489
6490#[cfg(all(
6491 test,
6492 feature = "tdm-aps",
6493 feature = "tdm-elsevier",
6494 feature = "tdm-springer",
6495 feature = "tdm-ieee"
6496))]
6497#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
6498mod tdm_chain_tests {
6499 use super::*;
6500
6501 use std::sync::Arc;
6502
6503 use camino::Utf8PathBuf;
6504 use tempfile::TempDir;
6505 use wiremock::matchers::{header, method, path, path_regex};
6506 use wiremock::{Mock, MockServer, ResponseTemplate};
6507
6508 use crate::http::HttpClient;
6509 use crate::provenance::ProvenanceLog;
6510 use crate::rate_limiter::RateLimiter;
6511 use crate::{CapabilityProfile, Doi, RateLimits, Ref, TdmGrant};
6512
6513 struct BaseGuard(Vec<(&'static str, Option<String>)>);
6516 impl BaseGuard {
6517 fn to(uri: &str) -> Self {
6518 const VARS: &[&str] = &[
6519 "DOIGET_APS_BASE",
6520 "DOIGET_ELSEVIER_BASE",
6521 "DOIGET_SPRINGER_BASE",
6522 "DOIGET_IEEE_BASE",
6523 ];
6524 Self(
6525 VARS.iter()
6526 .map(|v| {
6527 let old = std::env::var(v).ok();
6528 std::env::set_var(v, uri);
6529 (*v, old)
6530 })
6531 .collect(),
6532 )
6533 }
6534 }
6535 impl Drop for BaseGuard {
6536 fn drop(&mut self) {
6537 for (v, old) in &self.0 {
6538 match old {
6539 Some(o) => std::env::set_var(v, o),
6540 None => std::env::remove_var(v),
6541 }
6542 }
6543 }
6544 }
6545
6546 struct OaBaseGuard(Vec<(&'static str, Option<String>)>);
6551 impl OaBaseGuard {
6552 fn to(uri: &str) -> Self {
6553 const VARS: &[&str] = &[
6554 "DOIGET_CROSSREF_BASE",
6555 "DOIGET_UNPAYWALL_BASE",
6556 "DOIGET_ARXIV_BASE",
6557 ];
6558 Self(
6559 VARS.iter()
6560 .map(|v| {
6561 let old = std::env::var(v).ok();
6562 std::env::set_var(v, uri);
6563 (*v, old)
6564 })
6565 .collect(),
6566 )
6567 }
6568 }
6569 impl Drop for OaBaseGuard {
6570 fn drop(&mut self) {
6571 for (v, old) in &self.0 {
6572 match old {
6573 Some(o) => std::env::set_var(v, o),
6574 None => std::env::remove_var(v),
6575 }
6576 }
6577 }
6578 }
6579
6580 fn ctx_for(host: &str) -> (TempDir, FetchContext) {
6581 let td = TempDir::new().expect("tempdir");
6582 let dir = Utf8PathBuf::try_from(td.path().to_path_buf()).expect("utf-8");
6583 let http = Arc::new(HttpClient::new_for_tests_allow_http_multi(&[
6584 ("tdm-aps", host),
6585 ("tdm-elsevier", host),
6586 ("tdm-springer", host),
6587 ("tdm-ieee", host),
6588 ]));
6589 let session_id = "01J0000000000000000000TDM".to_string();
6590 let log = Arc::new(
6591 ProvenanceLog::open(dir.join("t.jsonl"), session_id.clone()).expect("log opens"),
6592 );
6593 (
6594 td,
6595 FetchContext {
6596 http,
6597 rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
6598 log,
6599 session_id,
6600 cache_root: None,
6601 },
6602 )
6603 }
6604
6605 fn grant(agree_var: &str) -> TdmGrant {
6606 TdmGrant {
6607 api_key: secrecy::SecretString::from("test-key".to_string()),
6608 agree_env_var: agree_var.to_string(),
6609 ..Default::default()
6610 }
6611 }
6612
6613 fn all_gates_open() -> CapabilityProfile {
6614 let mut p = CapabilityProfile::for_tests();
6615 p.tdm_aps = Some(grant("DOIGET_AGREE_TDM_APS"));
6616 p.tdm_elsevier = Some(grant("DOIGET_AGREE_TDM_ELSEVIER"));
6617 p.tdm_springer = Some(grant("DOIGET_AGREE_TDM_SPRINGER"));
6618 p.tdm_ieee = Some(grant("DOIGET_AGREE_TDM_IEEE"));
6619 p
6620 }
6621
6622 fn all_gates_closed() -> CapabilityProfile {
6623 let mut p = CapabilityProfile::for_tests();
6624 p.tdm_aps = None;
6625 p.tdm_elsevier = None;
6626 p.tdm_springer = None;
6627 p.tdm_ieee = None;
6628 p
6629 }
6630
6631 fn outcome<'a>(attempts: &'a [SourceAttempt], name: &str) -> &'a AttemptOutcome {
6632 &attempts
6633 .iter()
6634 .find(|a| a.source == name)
6635 .unwrap_or_else(|| panic!("no attempt recorded for {name}; got {attempts:?}"))
6636 .outcome
6637 }
6638
6639 #[tokio::test]
6643 #[serial_test::serial]
6644 async fn every_tdm_source_is_reached_for_its_own_publishers_doi() {
6645 for (doi, expected) in [
6646 ("10.1103/PhysRevX.10.011001", "tdm-aps"),
6647 ("10.1016/j.example.2024.001", "tdm-elsevier"),
6648 ("10.1007/s00220-024-05001-x", "tdm-springer"),
6649 ("10.1109/TSP.2018.2812747", "tdm-ieee"),
6650 ("10.23919/example.2024.001", "tdm-ieee"),
6653 ] {
6654 let server = MockServer::start().await;
6655 Mock::given(method("GET"))
6656 .respond_with(ResponseTemplate::new(200).set_body_string("{}"))
6657 .mount(&server)
6658 .await;
6659 let _bases = BaseGuard::to(&server.uri());
6660 let (_td, ctx) = ctx_for(&server.address().to_string());
6661
6662 let ref_ = Ref::Doi(Doi::parse(doi).expect("doi"));
6663 let mut attempts = Vec::new();
6664 resolve_tdm_chain(&ref_, &all_gates_open(), &ctx, false, &mut attempts).await;
6665
6666 let o = outcome(&attempts, expected);
6667 assert!(
6668 o.was_consulted(),
6669 "{expected} was NOT reached for {doi}: {o:?}"
6670 );
6671 assert_eq!(
6672 server.received_requests().await.expect("recorded").len(),
6673 1,
6674 "{expected} must issue exactly one request for {doi}"
6675 );
6676 }
6677 }
6678
6679 #[tokio::test]
6683 #[serial_test::serial]
6684 async fn a_foreign_doi_is_wrong_publisher_not_disabled() {
6685 let server = MockServer::start().await;
6686 let _bases = BaseGuard::to(&server.uri());
6687 let (_td, ctx) = ctx_for(&server.address().to_string());
6688
6689 let ref_ = Ref::Doi(Doi::parse("10.1090/s0025-5718-04-01692-8").expect("doi"));
6694 let mut attempts = Vec::new();
6695 resolve_tdm_chain(&ref_, &all_gates_open(), &ctx, false, &mut attempts).await;
6696
6697 for name in ["tdm-aps", "tdm-elsevier", "tdm-springer", "tdm-ieee"] {
6698 let o = outcome(&attempts, name);
6699 assert!(
6700 matches!(o, AttemptOutcome::WrongPublisher { .. }),
6701 "{name} must be WrongPublisher for an AMS DOI, got {o:?}"
6702 );
6703 assert!(
6704 !o.render().contains("DOIGET_KEY"),
6705 "must not suggest a credential that would not help: {}",
6706 o.render()
6707 );
6708 assert!(
6709 o.render().contains("10.1090"),
6710 "the message must name the prefix that did not match: {}",
6711 o.render()
6712 );
6713 }
6714 assert!(
6715 server
6716 .received_requests()
6717 .await
6718 .expect("recorded")
6719 .is_empty(),
6720 "a foreign DOI must cost the publisher nothing"
6721 );
6722 }
6723
6724 #[tokio::test]
6728 #[serial_test::serial]
6729 async fn closed_gates_name_the_key_and_the_agreement() {
6730 let server = MockServer::start().await;
6731 let _bases = BaseGuard::to(&server.uri());
6732 let (_td, ctx) = ctx_for(&server.address().to_string());
6733
6734 let ref_ = Ref::Doi(Doi::parse("10.1103/PhysRevX.10.011001").expect("doi"));
6735 let mut attempts = Vec::new();
6736 resolve_tdm_chain(&ref_, &all_gates_closed(), &ctx, false, &mut attempts).await;
6737
6738 let o = outcome(&attempts, "tdm-aps");
6739 assert_eq!(
6740 o,
6741 &AttemptOutcome::Disabled {
6742 env: &["DOIGET_KEY_APS", "DOIGET_AGREE_TDM_APS"]
6743 },
6744 "a closed Tier-3 gate must name the key AND the agreement"
6745 );
6746 assert!(!o.was_consulted());
6747 assert!(
6748 server
6749 .received_requests()
6750 .await
6751 .expect("recorded")
6752 .is_empty(),
6753 "closed gates must make NO request"
6754 );
6755 }
6756
6757 #[tokio::test]
6760 #[serial_test::serial]
6761 async fn a_crossref_hit_skips_the_tdm_chain() {
6762 let server = MockServer::start().await;
6763 let _bases = BaseGuard::to(&server.uri());
6764 let (_td, ctx) = ctx_for(&server.address().to_string());
6765
6766 let ref_ = Ref::Doi(Doi::parse("10.1103/PhysRevX.10.011001").expect("doi"));
6767 let mut attempts = Vec::new();
6768 resolve_tdm_chain(&ref_, &all_gates_open(), &ctx, true, &mut attempts).await;
6769
6770 for name in ["tdm-aps", "tdm-elsevier", "tdm-springer", "tdm-ieee"] {
6771 assert_eq!(
6772 outcome(&attempts, name),
6773 &AttemptOutcome::NotNeeded,
6774 "{name} must be NotNeeded -- the gates ARE open"
6775 );
6776 }
6777 assert!(server
6778 .received_requests()
6779 .await
6780 .expect("recorded")
6781 .is_empty());
6782 }
6783 #[tokio::test]
6790 #[serial_test::serial]
6791 async fn fetch_paper_actually_reaches_the_tdm_chain() {
6792 let server = MockServer::start().await;
6793 Mock::given(method("GET"))
6797 .respond_with(ResponseTemplate::new(404))
6798 .mount(&server)
6799 .await;
6800
6801 let _bases = BaseGuard::to(&server.uri());
6802 let _oa = OaBaseGuard::to(&server.uri());
6803 let (_td, ctx) = ctx_for(&server.address().to_string());
6804
6805 let store_td = TempDir::new().expect("tempdir");
6806 let root = Utf8PathBuf::try_from(store_td.path().to_path_buf()).expect("utf-8");
6807 let store = crate::store::FsStore::new(root.clone()).expect("store");
6808
6809 let ref_ = Ref::Doi(Doi::parse("10.1103/PhysRevX.10.011001").expect("doi"));
6810 let err = fetch_paper(&ref_, &all_gates_open(), &ctx, &store, &root)
6811 .await
6812 .expect_err("everything 404s, so the fetch must fail");
6813
6814 let paths: Vec<String> = server
6816 .received_requests()
6817 .await
6818 .expect("recorded")
6819 .iter()
6820 .map(|r| r.url.path().to_string())
6821 .collect();
6822 const APS_DOCUMENTED_PREFIX: &str = "/v2/journals/articles/";
6827 assert!(
6828 paths.iter().any(|p| p.contains(APS_DOCUMENTED_PREFIX)),
6829 "fetch_paper never reached tdm-aps at its documented endpoint; paths were {paths:?}"
6830 );
6831
6832 let hint = err.to_string();
6837 assert!(
6838 hint.contains("tdm-aps") && hint.contains("consulted:"),
6839 "the trace must record tdm-aps as consulted; got:
6840{hint}"
6841 );
6842 }
6843 fn ctx_for_content(host: &str) -> (TempDir, FetchContext) {
6855 let td = TempDir::new().expect("tempdir");
6856 let dir = Utf8PathBuf::try_from(td.path().to_path_buf()).expect("utf-8");
6857 let http = Arc::new(HttpClient::new_for_tests_allow_http_multi(&[
6858 ("crossref", host),
6859 ("unpaywall", host),
6860 ("oa-publisher", host),
6861 ("tdm-aps", host),
6862 ("tdm-elsevier", host),
6863 ("tdm-springer", host),
6864 ("tdm-ieee", host),
6865 ]));
6866 let session_id = "01J0000000000000000000CNT".to_string();
6867 let log = Arc::new(
6868 ProvenanceLog::open(dir.join("t.jsonl"), session_id.clone()).expect("log opens"),
6869 );
6870 (
6871 td,
6872 FetchContext {
6873 http,
6874 rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
6875 log,
6876 session_id,
6877 cache_root: None,
6878 },
6879 )
6880 }
6881
6882 fn crossref_body() -> serde_json::Value {
6886 serde_json::json!({
6887 "status": "ok",
6888 "message": {
6889 "title": ["A paper APS published"],
6890 "author": [{ "family": "Doe", "given": "Jane" }],
6891 "issued": { "date-parts": [[2026, 1, 1]] },
6892 "container-title": ["Physical Review X"],
6893 "type": "journal-article"
6894 }
6895 })
6896 }
6897
6898 fn unpaywall_body(oa_url: &str) -> serde_json::Value {
6903 serde_json::json!({
6904 "doi": "10.1103/PhysRevX.10.011001",
6905 "is_oa": true,
6906 "title": "A paper APS published",
6907 "best_oa_location": {
6908 "url": oa_url,
6909 "url_for_pdf": oa_url,
6910 "license": "cc-by"
6911 }
6912 })
6913 }
6914
6915 const PDF_BYTES: &[u8] = b"%PDF-1.7\nthe publisher's own copy\n%%EOF\n";
6916
6917 async fn mount_oa_blocked(server: &MockServer, aps: ResponseTemplate) {
6923 let oa_url = format!("{}/oa/file.pdf", server.uri());
6924 Mock::given(method("GET"))
6925 .and(path_regex("^/works/"))
6926 .respond_with(ResponseTemplate::new(200).set_body_json(crossref_body()))
6927 .mount(server)
6928 .await;
6929 Mock::given(method("GET"))
6934 .and(path_regex("^/v2/journals/articles/"))
6935 .and(header("accept", "application/pdf"))
6936 .respond_with(aps)
6937 .mount(server)
6938 .await;
6939 Mock::given(method("GET"))
6940 .and(path("/oa/file.pdf"))
6941 .respond_with(ResponseTemplate::new(403))
6942 .mount(server)
6943 .await;
6944 Mock::given(method("GET"))
6945 .respond_with(ResponseTemplate::new(200).set_body_json(unpaywall_body(&oa_url)))
6946 .mount(server)
6947 .await;
6948 }
6949
6950 fn aps_pdf_requests(reqs: &[wiremock::Request]) -> usize {
6951 reqs.iter()
6952 .filter(|r| {
6953 r.url.path().starts_with("/v2/journals/articles/")
6954 && r.headers
6955 .get("accept")
6956 .and_then(|v| v.to_str().ok())
6957 .is_some_and(|v| v.contains("application/pdf"))
6958 })
6959 .count()
6960 }
6961
6962 #[tokio::test]
6969 #[serial_test::serial]
6970 async fn tdm_content_leg_serves_the_pdf_when_the_oa_route_is_blocked() {
6971 let server = MockServer::start().await;
6972 mount_oa_blocked(
6973 &server,
6974 ResponseTemplate::new(200).set_body_bytes(PDF_BYTES),
6975 )
6976 .await;
6977
6978 let _bases = BaseGuard::to(&server.uri());
6979 let _oa = OaBaseGuard::to(&server.uri());
6980 let (_td, ctx) = ctx_for_content(&server.address().to_string());
6981
6982 let store_td = TempDir::new().expect("tempdir");
6983 let root = Utf8PathBuf::try_from(store_td.path().to_path_buf()).expect("utf-8");
6984 let store = crate::store::FsStore::new(root.clone()).expect("store");
6985
6986 let ref_ = Ref::Doi(Doi::parse("10.1103/PhysRevX.10.011001").expect("doi"));
6987 let outcome = fetch_paper(&ref_, &all_gates_open(), &ctx, &store, &root)
6988 .await
6989 .expect("the TDM content leg should have supplied the PDF");
6990
6991 let reqs = server.received_requests().await.expect("recorded");
6995 assert_eq!(
6996 aps_pdf_requests(&reqs),
6997 1,
6998 "expected exactly one Accept: application/pdf request to the APS article endpoint; \
6999 paths were {:?}",
7000 reqs.iter().map(|r| r.url.path()).collect::<Vec<_>>()
7001 );
7002
7003 match &outcome.pdf_leg {
7005 PdfLegStatus::TdmFetched {
7006 source,
7007 original_block,
7008 } => {
7009 assert_eq!(source, "tdm-aps");
7010 assert!(
7011 !original_block.is_empty(),
7012 "the OA refusal must be carried forward, not discarded"
7013 );
7014 }
7015 other => panic!("expected TdmFetched, got {other:?}"),
7016 }
7017
7018 assert_eq!(outcome.source, "tdm-aps");
7020 assert_eq!(outcome.size_bytes, PDF_BYTES.len() as u64);
7021
7022 assert_eq!(
7027 outcome.license, "unknown",
7028 "a TDM-retrieved copy must not inherit the OA location's licence"
7029 );
7030 }
7031
7032 #[tokio::test]
7037 #[serial_test::serial]
7038 async fn tdm_content_leg_is_not_consulted_for_another_publishers_doi() {
7039 let server = MockServer::start().await;
7040 mount_oa_blocked(
7041 &server,
7042 ResponseTemplate::new(200).set_body_bytes(PDF_BYTES),
7043 )
7044 .await;
7045
7046 let _bases = BaseGuard::to(&server.uri());
7047 let _oa = OaBaseGuard::to(&server.uri());
7048 let (_td, ctx) = ctx_for_content(&server.address().to_string());
7049
7050 let store_td = TempDir::new().expect("tempdir");
7051 let root = Utf8PathBuf::try_from(store_td.path().to_path_buf()).expect("utf-8");
7052 let store = crate::store::FsStore::new(root.clone()).expect("store");
7053
7054 let ref_ = Ref::Doi(Doi::parse("10.1016/j.physrep.2020.01.001").expect("doi"));
7056 let _ = fetch_paper(&ref_, &all_gates_open(), &ctx, &store, &root).await;
7057
7058 let reqs = server.received_requests().await.expect("recorded");
7059 assert_eq!(
7060 aps_pdf_requests(&reqs),
7061 0,
7062 "an Elsevier DOI reached the APS content endpoint; paths were {:?}",
7063 reqs.iter().map(|r| r.url.path()).collect::<Vec<_>>()
7064 );
7065 }
7066
7067 #[tokio::test]
7080 #[serial_test::serial]
7081 async fn content_leg_reports_a_foreign_doi_as_wrong_publisher_not_disabled() {
7082 let server = MockServer::start().await;
7083 let _bases = BaseGuard::to(&server.uri());
7084 let (_td, ctx) = ctx_for_content(&server.address().to_string());
7085
7086 let doi = Doi::parse("10.1016/j.physrep.2020.01.001").expect("doi");
7087 let blocked = PdfLegStatus::Blocked {
7088 code: crate::ErrorCode::NetworkError,
7089 message: "the open route refused us".to_string(),
7090 denial: None,
7091 suggested_arxiv_id: None,
7092 };
7093 let mut attempts: Vec<SourceAttempt> = Vec::new();
7094
7095 let (leg, bytes) =
7096 try_tdm_content_fallback(&doi, blocked, None, &all_gates_open(), &ctx, &mut attempts)
7097 .await;
7098
7099 assert!(bytes.is_none(), "no publisher owns this DOI here");
7100 assert!(matches!(leg, PdfLegStatus::Blocked { .. }));
7101 assert!(
7102 matches!(outcome(&attempts, "tdm-aps"), AttemptOutcome::WrongPublisher { .. }),
7103 "an Elsevier DOI must read as WrongPublisher for tdm-aps, not Disabled; got {attempts:?}"
7104 );
7105 }
7106
7107 #[tokio::test]
7114 #[serial_test::serial]
7115 async fn tdm_content_leg_rejects_a_non_pdf_body_and_keeps_the_original_block() {
7116 let server = MockServer::start().await;
7117 mount_oa_blocked(
7118 &server,
7119 ResponseTemplate::new(200).set_body_string("<html>Access denied</html>"),
7120 )
7121 .await;
7122
7123 let _bases = BaseGuard::to(&server.uri());
7124 let _oa = OaBaseGuard::to(&server.uri());
7125 let (_td, ctx) = ctx_for_content(&server.address().to_string());
7126
7127 let store_td = TempDir::new().expect("tempdir");
7128 let root = Utf8PathBuf::try_from(store_td.path().to_path_buf()).expect("utf-8");
7129 let store = crate::store::FsStore::new(root.clone()).expect("store");
7130
7131 let ref_ = Ref::Doi(Doi::parse("10.1103/PhysRevX.10.011001").expect("doi"));
7132 let outcome = fetch_paper(&ref_, &all_gates_open(), &ctx, &store, &root)
7133 .await
7134 .expect("a metadata-only outcome is still an outcome");
7135
7136 let reqs = server.received_requests().await.expect("recorded");
7138 assert_eq!(aps_pdf_requests(&reqs), 1);
7139
7140 match &outcome.pdf_leg {
7142 PdfLegStatus::Blocked { message, .. } => {
7143 assert!(
7144 !message.is_empty(),
7145 "the ORIGINAL OA refusal must survive, not the TDM failure"
7146 );
7147 }
7148 other => panic!("expected the original Blocked leg to survive, got {other:?}"),
7149 }
7150 assert_eq!(outcome.size_bytes, 0, "nothing should have been stored");
7151 }
7152}
7153
7154#[cfg(all(test, feature = "metadata"))]
7165#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
7166mod oa_fallthrough_tests {
7167 use super::*;
7168
7169 use std::sync::Arc;
7170
7171 use camino::Utf8PathBuf;
7172 use tempfile::TempDir;
7173 use wiremock::matchers::{path, path_regex};
7174 use wiremock::{Mock, MockServer, ResponseTemplate};
7175
7176 use crate::http::HttpClient;
7177 use crate::provenance::ProvenanceLog;
7178 use crate::rate_limiter::RateLimiter;
7179 use crate::{CapabilityProfile, Doi, RateLimits, Ref};
7180
7181 struct EnvSet(Vec<(&'static str, Option<String>)>);
7182 impl EnvSet {
7183 fn new(pairs: &[(&'static str, String)]) -> Self {
7184 Self(
7185 pairs
7186 .iter()
7187 .map(|(k, v)| {
7188 let old = std::env::var(k).ok();
7189 std::env::set_var(k, v);
7190 (*k, old)
7191 })
7192 .collect(),
7193 )
7194 }
7195 }
7196 impl Drop for EnvSet {
7197 fn drop(&mut self) {
7198 for (k, old) in &self.0 {
7199 match old {
7200 Some(v) => std::env::set_var(k, v),
7201 None => std::env::remove_var(k),
7202 }
7203 }
7204 }
7205 }
7206
7207 async fn server_with_a_rate_limited_publisher_and_a_repository_copy() -> MockServer {
7210 let server = MockServer::start().await;
7211 let base = server.uri();
7212
7213 Mock::given(path_regex("^/works/"))
7214 .respond_with(ResponseTemplate::new(200).set_body_string(
7215 "{\"status\":\"ok\",\"message\":{\"title\":[\"Computing multiple roots\"]}}",
7216 ))
7217 .mount(&server)
7218 .await;
7219 Mock::given(path_regex("^/10\\.1090"))
7220 .respond_with(ResponseTemplate::new(200).set_body_string(format!(
7221 "{{\"doi\":\"10.1090/example\",\"is_oa\":true,\"oa_status\":\"bronze\",\"best_oa_location\":\
7222 {{\"url_for_pdf\":\"{base}/blocked.pdf\",\"license\":\"cc-by\"}}}}"
7223 )))
7224 .mount(&server)
7225 .await;
7226 Mock::given(path("/blocked.pdf"))
7229 .respond_with(ResponseTemplate::new(429))
7230 .mount(&server)
7231 .await;
7232 Mock::given(path("/v3/search/works"))
7233 .respond_with(ResponseTemplate::new(200).set_body_string(format!(
7234 "{{\"totalHits\":1,\"results\":[{{\"id\":1,\
7235 \"title\":\"Computing multiple roots\",\"doi\":\"10.1090/example\",\
7236 \"downloadUrl\":\"{base}/repo.pdf\"}}]}}"
7237 )))
7238 .mount(&server)
7239 .await;
7240 Mock::given(path("/repo.pdf"))
7241 .respond_with(
7242 ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.4\nrepository copy\n".to_vec()),
7243 )
7244 .mount(&server)
7245 .await;
7246 server
7247 }
7248
7249 fn ctx_for(host: &str) -> (TempDir, FetchContext) {
7250 let td = TempDir::new().expect("tempdir");
7251 let dir = Utf8PathBuf::try_from(td.path().to_path_buf()).expect("utf-8");
7252 let host_only = host.split(':').next().unwrap_or(host);
7253 let http = Arc::new(HttpClient::new_for_tests_allow_http_multi(&[
7254 ("crossref", host),
7255 ("unpaywall", host),
7256 ("oa-publisher", host_only),
7259 ("core", host),
7260 ]));
7261 let session_id = "01J000000000000000000FALL".to_string();
7262 let log = Arc::new(
7263 ProvenanceLog::open(dir.join("t.jsonl"), session_id.clone()).expect("log opens"),
7264 );
7265 (
7266 td,
7267 FetchContext {
7268 http,
7269 rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
7270 log,
7271 session_id,
7272 cache_root: None,
7273 },
7274 )
7275 }
7276
7277 async fn run_fetch(
7278 server: &MockServer,
7279 core_enabled: bool,
7280 ) -> (FetchPaperOutcome, Vec<String>) {
7281 let base = server.uri();
7282 let mut env = vec![
7283 ("DOIGET_CROSSREF_BASE", base.clone()),
7284 ("DOIGET_UNPAYWALL_BASE", base.clone()),
7285 ("DOIGET_ARXIV_BASE", base.clone()),
7286 ("DOIGET_CORE_BASE", base.clone()),
7287 ("DOIGET_CONTACT_EMAIL", "test@example.org".to_string()),
7288 ];
7289 if core_enabled {
7290 env.push(("DOIGET_ENABLE_CORE", "1".to_string()));
7291 } else {
7292 std::env::remove_var("DOIGET_ENABLE_CORE");
7293 }
7294 let _env = EnvSet::new(&env);
7295
7296 let profile = CapabilityProfile::from_env().expect("profile");
7297 let (_td, ctx) = ctx_for(&server.address().to_string());
7298 let store_td = TempDir::new().expect("tempdir");
7299 let root = Utf8PathBuf::try_from(store_td.path().to_path_buf()).expect("utf-8");
7300 let store = crate::store::FsStore::new(root.clone()).expect("store");
7301
7302 let ref_ = Ref::Doi(Doi::parse("10.1090/example").expect("doi"));
7303 let outcome = fetch_paper(&ref_, &profile, &ctx, &store, &root)
7304 .await
7305 .expect("crossref answered, so the fetch resolves either way");
7306 let paths = server
7307 .received_requests()
7308 .await
7309 .expect("recorded")
7310 .iter()
7311 .map(|r| r.url.path().to_string())
7312 .collect();
7313 (outcome, paths)
7314 }
7315
7316 #[tokio::test]
7319 #[serial_test::serial]
7320 async fn a_rate_limited_publisher_falls_through_to_an_enabled_source() {
7321 let server = server_with_a_rate_limited_publisher_and_a_repository_copy().await;
7322 let (outcome, paths) = run_fetch(&server, true).await;
7323
7324 assert!(
7325 paths.iter().any(|p| p == "/v3/search/works"),
7326 "CORE was never consulted; paths were {paths:?}"
7327 );
7328 assert!(
7329 paths.iter().any(|p| p == "/repo.pdf"),
7330 "the copy CORE reported was never fetched; paths were {paths:?}; leg={:?}",
7331 outcome.pdf_leg
7332 );
7333 assert!(
7334 matches!(outcome.pdf_leg, PdfLegStatus::Fetched),
7335 "the run should have recovered; got {:?}",
7336 outcome.pdf_leg
7337 );
7338 }
7339
7340 #[cfg(feature = "metadata")]
7343 #[test]
7344 fn a_datacite_software_record_cites_as_software_614() {
7345 let outcome: MetadataOnlyOutcome = serde_json::from_value(serde_json::json!({
7346 "source": "datacite", "resolver_profile": "test", "license": null, "oa_url": null,
7347 "metadata": {
7348 "titles": [{"title": "HFDMRG.jl"}],
7349 "creators": [{"name": "White, Steven R."}],
7350 "publicationYear": 2023,
7351 "publisher": "Zenodo",
7352 "types": {"resourceTypeGeneral": "Software"},
7353 "version": "v0.1.0",
7354 "url": "https://zenodo.org/records/200"
7355 }
7356 }))
7357 .expect("outcome");
7358 let ref_ = Ref::Doi(Doi::parse("10.5281/zenodo.200").expect("doi"));
7359 let m = cite_metadata(&ref_, &outcome);
7360 let bib = crate::store::render::to_bibtex("hf", &m);
7361 assert!(bib.starts_with("@software{hf,"), "{bib}");
7362 assert!(bib.contains("author = {White, Steven R.}"), "{bib}");
7363 assert!(bib.contains("year = {2023}"), "{bib}");
7364 assert!(bib.contains("version = {v0.1.0}"), "{bib}");
7365 assert!(
7366 bib.contains("url = {https://zenodo.org/records/200}"),
7367 "{bib}"
7368 );
7369 assert!(bib.contains("publisher = {Zenodo}"), "{bib}");
7370 }
7371
7372 async fn fetch_closed_record(openalex_enabled: bool) -> (FetchPaperOutcome, Vec<String>) {
7380 let server = MockServer::start().await;
7381 Mock::given(path_regex("^/works/10\\.1109"))
7382 .respond_with(ResponseTemplate::new(200).set_body_string(
7383 r#"{"status":"ok","message":{"title":["Eigenvalue Decomposition"],"type":"journal-article"}}"#,
7384 ))
7385 .mount(&server)
7386 .await;
7387 Mock::given(path_regex("^/works/doi:10\\.1109"))
7388 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
7389 "id": "https://openalex.org/W1",
7390 "locations": [
7391 {"is_oa": false, "pdf_url": null,
7392 "landing_page_url": "https://doi.org/10.1109/tsp.2023.3269664",
7393 "source": {"display_name": "IEEE Transactions on Signal Processing"}},
7394 {"is_oa": false, "pdf_url": null,
7395 "landing_page_url": "https://strathprints.strath.ac.uk/view/author/70486.html>",
7396 "source": {"display_name": "Strathprints: The University of Strathclyde"}}
7397 ]
7398 })))
7399 .mount(&server)
7400 .await;
7401 Mock::given(path_regex("^/10\\.1109"))
7402 .respond_with(ResponseTemplate::new(200).set_body_string(
7403 r#"{"doi":"10.1109/tsp.2023.3269664","is_oa":false,"oa_status":"closed","best_oa_location":null,"oa_locations":[]}"#,
7404 ))
7405 .mount(&server)
7406 .await;
7407 let base = server.uri();
7408 let mut env = vec![
7409 ("DOIGET_CROSSREF_BASE", base.clone()),
7410 ("DOIGET_UNPAYWALL_BASE", base.clone()),
7411 ("DOIGET_OPENALEX_BASE", base.clone()),
7412 ("DOIGET_CONTACT_EMAIL", "test@example.org".to_string()),
7413 ];
7414 if openalex_enabled {
7415 env.push(("DOIGET_ENABLE_OPENALEX", "1".to_string()));
7416 }
7417 let _env = EnvSet::new(&env);
7418 let profile = CapabilityProfile::from_env().expect("profile");
7419 let host = server.address().to_string();
7420 let td = TempDir::new().expect("tempdir");
7421 let dir = Utf8PathBuf::try_from(td.path().to_path_buf()).expect("utf-8");
7422 let session_id = "01J000000000000000000FALL".to_string();
7423 let ctx = FetchContext {
7424 http: Arc::new(HttpClient::new_for_tests_allow_http_multi(&[
7425 ("crossref", host.as_str()),
7426 ("unpaywall", host.as_str()),
7427 ("openalex", host.as_str()),
7428 ])),
7429 rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
7430 log: Arc::new(
7431 ProvenanceLog::open(dir.join("t.jsonl"), session_id.clone()).expect("log"),
7432 ),
7433 session_id,
7434 cache_root: None,
7435 };
7436 let store_td = TempDir::new().expect("tempdir");
7437 let root = Utf8PathBuf::try_from(store_td.path().to_path_buf()).expect("utf-8");
7438 let store = crate::store::FsStore::new(root.clone()).expect("store");
7439 let ref_ = Ref::Doi(Doi::parse("10.1109/tsp.2023.3269664").expect("doi"));
7440 let outcome = fetch_paper(&ref_, &profile, &ctx, &store, &root)
7441 .await
7442 .expect("resolves");
7443 let paths = server
7444 .received_requests()
7445 .await
7446 .expect("recorded")
7447 .iter()
7448 .map(|r| r.url.path().to_string())
7449 .collect();
7450 (outcome, paths)
7451 }
7452
7453 #[tokio::test]
7454 #[serial_test::serial]
7455 async fn a_closed_record_still_asks_an_enabled_openalex_and_names_the_deposit() {
7456 let (outcome, paths) = fetch_closed_record(true).await;
7457 assert!(
7459 paths.iter().any(|p| p.starts_with("/works/doi:")),
7460 "{paths:?}"
7461 );
7462
7463 assert!(
7464 matches!(outcome.pdf_leg, PdfLegStatus::NoOaUrl),
7465 "{:?}",
7466 outcome.pdf_leg
7467 );
7468 let row = outcome
7469 .attempts
7470 .iter()
7471 .find(|a| a.source == "openalex")
7472 .expect("an openalex row");
7473 let AttemptOutcome::NotOpenAccess { detail } = &row.outcome else {
7474 panic!(
7475 "OpenAlex must have been asked and reported what it named; got {:?}",
7476 row.outcome
7477 );
7478 };
7479 assert!(detail.contains("Strathprints"), "{detail}");
7480 assert!(
7481 detail.contains("malformed"),
7482 "the stray `>` is named: {detail}"
7483 );
7484 }
7485
7486 #[tokio::test]
7489 #[serial_test::serial]
7490 async fn with_no_source_enabled_a_closed_record_asks_nothing_more() {
7491 let (outcome, paths) = fetch_closed_record(false).await;
7492 assert!(
7493 matches!(outcome.pdf_leg, PdfLegStatus::NoOaUrl),
7494 "{:?}",
7495 outcome.pdf_leg
7496 );
7497 assert!(
7498 !paths.iter().any(|p| p.starts_with("/works/doi:")),
7499 "a disabled OpenAlex must cost nothing; paths were {paths:?}"
7500 );
7501 }
7502
7503 #[tokio::test]
7507 #[serial_test::serial]
7508 async fn with_no_source_enabled_the_run_is_unchanged() {
7509 let server = server_with_a_rate_limited_publisher_and_a_repository_copy().await;
7510 let (outcome, paths) = run_fetch(&server, false).await;
7511
7512 assert!(
7513 !paths.iter().any(|p| p == "/v3/search/works"),
7514 "a disabled source must cost nothing; paths were {paths:?}"
7515 );
7516 assert!(
7517 matches!(outcome.pdf_leg, PdfLegStatus::Blocked { .. }),
7518 "without a fallback source this must still be Blocked; got {:?}",
7519 outcome.pdf_leg
7520 );
7521 }
7522 #[cfg(feature = "tdm-ieee")]
7535 #[tokio::test]
7536 #[serial_test::serial]
7537 async fn the_fallback_preserves_the_tier_3_rows_it_used_to_delete() {
7538 let server = server_with_a_rate_limited_publisher_and_a_repository_copy().await;
7539 let (outcome, paths) = run_fetch(&server, true).await;
7540
7541 assert!(
7543 paths.iter().any(|p| p == "/v3/search/works"),
7544 "the fallback did not run, so this test cannot see the bug; paths were {paths:?}"
7545 );
7546
7547 let sources: Vec<&str> = outcome.attempts.iter().map(|a| a.source).collect();
7548 assert!(
7549 sources.contains(&"tdm-ieee"),
7550 "the Tier-3 row was dropped from the trace by the fallback; trace held {sources:?}"
7551 );
7552 assert!(
7554 sources.contains(&"core"),
7555 "the refreshed Tier-2 rows are missing; trace held {sources:?}"
7556 );
7557 }
7558}