1use async_trait::async_trait;
46use bytes::Bytes;
47use quick_xml::events::Event;
48use quick_xml::Reader;
49use serde_json::{json, Value};
50use url::Url;
51
52use crate::provenance::{Capability, LogEvent, LogResult, RowInput};
53use crate::source::{FetchContext, FetchError, FetchResult, Source};
54use crate::{ArxivId, CapabilityProfile, Ref};
55
56const PDF_BASE: &str = "https://arxiv.org";
62
63const META_BASE: &str = "https://export.arxiv.org";
69
70#[derive(Clone, Debug)]
73pub struct ArxivSource {
74 base: Url,
76 meta_base: Url,
78}
79
80impl ArxivSource {
81 pub fn new() -> Self {
84 #[allow(clippy::expect_used)]
89 let base = Url::parse(PDF_BASE).expect("hard-coded PDF base URL is valid");
90 #[allow(clippy::expect_used)]
91 let meta_base = Url::parse(META_BASE).expect("hard-coded meta base URL is valid");
92 Self { base, meta_base }
93 }
94
95 pub fn with_base(base: Url) -> Self {
104 Self {
105 meta_base: base.clone(),
106 base,
107 }
108 }
109
110 fn pdf_url(&self, id: &ArxivId) -> Result<Url, FetchError> {
122 let path = format!("/pdf/{}.pdf", id.as_str());
123 self.base.join(&path).map_err(|e| FetchError::SourceSchema {
124 hint: format!("arxiv URL construction failed: {e}"),
125 })
126 }
127
128 fn metadata_url(&self, id: &ArxivId) -> Result<Url, FetchError> {
141 let mut url = self
142 .meta_base
143 .join("/api/query")
144 .map_err(|e| FetchError::SourceSchema {
145 hint: format!("arxiv metadata URL construction failed: {e}"),
146 })?;
147 url.query_pairs_mut().append_pair("id_list", id.as_str());
148 Ok(url)
149 }
150
151 pub async fn fetch_metadata_only(
167 &self,
168 id: &ArxivId,
169 ctx: &FetchContext,
170 ) -> Result<Value, FetchError> {
171 let _permit = ctx.rate_limiter.acquire(self.name()).await;
173
174 let url = self.metadata_url(id)?;
175 let (body, _final_url) = ctx.http.fetch_bytes(self.name(), url).await?;
176 let metadata = parse_atom_feed(&body)?;
177
178 let canonical =
182 crate::CanonicalRef::new(crate::SourceType::Arxiv, id.as_str(), self.name(), None)
183 .digest_hex();
184 ctx.log.append(RowInput {
185 event: LogEvent::Fetch,
186 result: LogResult::Ok,
187 capability: Capability::Metadata,
192 ref_: Some(id.as_str()),
193 source: Some(self.name()),
194 error_code: None,
195 size_bytes: Some(body.len() as u64),
196 license: Some("arxiv-default"),
197 store_path: None,
198 canonical_digest: Some(&canonical),
199 })?;
200
201 Ok(metadata)
202 }
203}
204
205impl Default for ArxivSource {
206 fn default() -> Self {
207 Self::new()
208 }
209}
210
211#[async_trait]
212impl Source for ArxivSource {
213 fn name(&self) -> &str {
214 "arxiv"
215 }
216
217 fn can_serve(&self, _profile: &CapabilityProfile, ref_: &Ref) -> bool {
218 matches!(ref_, Ref::Arxiv(_))
219 }
220
221 async fn fetch(
222 &self,
223 ref_: &Ref,
224 _profile: &CapabilityProfile,
225 ctx: &FetchContext,
226 ) -> Result<FetchResult, FetchError> {
227 let id = match ref_ {
231 Ref::Arxiv(a) => a,
232 Ref::Doi(_) => {
233 return Err(FetchError::NotEligible {
234 source_key: "arxiv".into(),
235 });
236 }
237 };
238
239 let _permit = ctx.rate_limiter.acquire(self.name()).await;
242
243 let metadata_json = match self.metadata_url(id) {
255 Ok(meta_url) => match ctx.http.fetch_bytes(self.name(), meta_url).await {
256 Ok((bytes, _final)) => match parse_atom_feed(&bytes) {
257 Ok(v) => Some(v),
258 Err(e) => {
259 tracing::warn!(
260 arxiv_id = %id.as_str(),
261 error = %e,
262 "arxiv Atom feed parse failed; continuing with PDF-only fetch"
263 );
264 None
265 }
266 },
267 Err(e) => {
268 tracing::warn!(
269 arxiv_id = %id.as_str(),
270 error = %e,
271 "arxiv Atom feed fetch failed; continuing with PDF-only fetch"
272 );
273 None
274 }
275 },
276 Err(e) => {
277 tracing::warn!(
278 arxiv_id = %id.as_str(),
279 error = %e,
280 "arxiv metadata URL construction failed; continuing with PDF-only fetch"
281 );
282 None
283 }
284 };
285
286 ctx.rate_limiter.pace(self.name()).await;
294
295 let url = self.pdf_url(id)?;
296
297 let (body, final_url): (Bytes, Url) = ctx.http.fetch_pdf(self.name(), url).await?;
301
302 let canonical = ref_.promote(self.name(), None).digest_hex();
309 ctx.log.append(RowInput {
310 event: LogEvent::Fetch,
311 result: LogResult::Ok,
312 capability: Capability::Oa,
313 ref_: Some(id.as_str()),
314 source: Some(self.name()),
315 error_code: None,
316 size_bytes: Some(body.len() as u64),
317 license: Some("arxiv-default"),
323 store_path: None,
324 canonical_digest: Some(&canonical),
325 })?;
326
327 Ok(FetchResult {
328 source: self.name().to_string(),
329 license: "arxiv-default".into(),
330 pdf_bytes: Some(body),
331 final_url: Some(final_url),
332 metadata_json,
333 })
334 }
335}
336
337pub(crate) fn parse_atom_feed(xml: &[u8]) -> Result<Value, FetchError> {
376 let mut reader = Reader::from_reader(xml);
377 let config = reader.config_mut();
378 config.trim_text(true);
379
380 let mut in_entry = false;
383 let mut saw_entry = false;
384 let mut depth = 0_i32; let mut title: Option<String> = None;
389 let mut abstract_: Option<String> = None;
390 let mut published: Option<String> = None;
391 let mut updated: Option<String> = None;
392 let mut authors: Vec<String> = Vec::new();
393 let mut categories: Vec<String> = Vec::new();
394 let mut doi: Option<String> = None;
399 let mut journal_ref: Option<String> = None;
400
401 #[derive(Clone, Copy)]
404 enum Target {
405 Title,
406 Summary,
407 Published,
408 Updated,
409 AuthorName,
410 Doi,
411 JournalRef,
412 }
413 let mut target: Option<Target> = None;
414 let mut in_author = false;
415 let mut buf: Vec<u8> = Vec::new();
416
417 loop {
418 match reader.read_event_into(&mut buf) {
419 Ok(Event::Start(e)) => {
420 let name = e.name();
421 let local = local_name(name.as_ref());
422 if !in_entry {
423 if local == "entry" {
424 in_entry = true;
425 saw_entry = true;
426 depth = 0;
427 }
428 buf.clear();
429 continue;
430 }
431 depth += 1;
432 if depth == 1 {
434 match local {
435 "title" => target = Some(Target::Title),
436 "summary" => target = Some(Target::Summary),
437 "published" => target = Some(Target::Published),
438 "updated" => target = Some(Target::Updated),
439 "doi" => target = Some(Target::Doi),
443 "journal_ref" => target = Some(Target::JournalRef),
444 "author" => {
445 in_author = true;
446 authors.push(String::new());
447 }
448 _ => {}
449 }
450 } else if depth == 2 && in_author && local == "name" {
451 target = Some(Target::AuthorName);
452 }
453 buf.clear();
454 }
455 Ok(Event::Empty(e)) => {
456 let name = e.name();
457 let local = local_name(name.as_ref());
458 if in_entry && depth == 0 && local == "category" {
459 for attr in e.attributes().flatten() {
461 if attr.key.as_ref() == "term" {
462 if let Ok(v) = attr.normalized_value(quick_xml::XmlVersion::Explicit1_0)
468 {
469 categories.push(v.into_owned());
470 }
471 }
472 }
473 }
474 buf.clear();
475 }
476 Ok(Event::Text(t)) => {
477 if let Some(tg) = target {
478 if let Some(s) = quick_xml::escape::unescape(&t).ok().map(|c| c.into_owned()) {
484 match tg {
485 Target::Title => title.get_or_insert_with(String::new).push_str(&s),
486 Target::Summary => {
487 abstract_.get_or_insert_with(String::new).push_str(&s)
488 }
489 Target::Published => {
490 published.get_or_insert_with(String::new).push_str(&s)
491 }
492 Target::Updated => updated.get_or_insert_with(String::new).push_str(&s),
493 Target::Doi => doi.get_or_insert_with(String::new).push_str(&s),
494 Target::JournalRef => {
495 journal_ref.get_or_insert_with(String::new).push_str(&s)
496 }
497 Target::AuthorName => {
498 if let Some(last) = authors.last_mut() {
499 last.push_str(&s);
500 }
501 }
502 }
503 }
504 }
505 buf.clear();
506 }
507 Ok(Event::End(e)) => {
508 if !in_entry {
509 buf.clear();
510 continue;
511 }
512 let name = e.name();
513 let local = local_name(name.as_ref());
514 if depth == 0 && local == "entry" {
515 break;
519 }
520 depth -= 1;
521 if depth == 0 {
522 if local == "author" {
523 in_author = false;
524 if let Some(last) = authors.last() {
526 if last.is_empty() {
527 authors.pop();
528 }
529 }
530 }
531 target = None;
532 } else if depth == 1 && in_author && local == "name" {
533 target = None;
534 }
535 buf.clear();
536 }
537 Ok(Event::Eof) => break,
538 Err(e) => {
539 return Err(FetchError::SourceSchema {
540 hint: format!("arxiv Atom XML parse error: {e}"),
541 });
542 }
543 _ => {
545 buf.clear();
546 }
547 }
548 }
549
550 if !saw_entry {
551 return Err(FetchError::NotFound {
556 hint: "arxiv Atom feed had no <entry> element (unknown id?)".into(),
557 });
558 }
559
560 let mut obj = serde_json::Map::new();
563 if let Some(t) = title {
564 let trimmed = t.trim().to_string();
565 if !trimmed.is_empty() {
566 obj.insert("title".into(), Value::String(trimmed));
567 }
568 }
569 if let Some(a) = abstract_ {
570 let trimmed = a.trim().to_string();
571 if !trimmed.is_empty() {
572 obj.insert("abstract".into(), Value::String(trimmed));
573 }
574 }
575 if !authors.is_empty() {
576 obj.insert(
577 "authors".into(),
578 Value::Array(authors.into_iter().map(Value::String).collect()),
579 );
580 }
581 if let Some(p) = published {
582 let trimmed = p.trim().to_string();
583 if !trimmed.is_empty() {
584 obj.insert("published".into(), Value::String(trimmed));
585 }
586 }
587 if let Some(u) = updated {
588 let trimmed = u.trim().to_string();
589 if !trimmed.is_empty() {
590 obj.insert("updated".into(), Value::String(trimmed));
591 }
592 }
593 if let Some(d) = doi {
604 let trimmed = d.trim().to_string();
605 if !trimmed.is_empty() {
606 obj.insert("doi".into(), Value::String(trimmed));
607 }
608 }
609 if let Some(j) = journal_ref {
610 let trimmed = j.trim().to_string();
611 if !trimmed.is_empty() {
612 obj.insert("journal_ref".into(), Value::String(trimmed));
613 }
614 }
615 if !categories.is_empty() {
616 obj.insert(
617 "categories".into(),
618 Value::Array(categories.into_iter().map(Value::String).collect()),
619 );
620 }
621 Ok(json!(obj))
622}
623
624fn local_name(qname: &str) -> &str {
631 match qname.rfind(':') {
632 Some(idx) => &qname[idx + 1..],
633 None => qname,
634 }
635}
636
637#[cfg(test)]
642#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
643mod tests {
644 use super::*;
645
646 use std::sync::Arc;
647
648 use camino::Utf8PathBuf;
649 use tempfile::TempDir;
650 use wiremock::matchers::{method, path};
651 use wiremock::{Mock, MockServer, ResponseTemplate};
652
653 use crate::http::{HttpClient, HttpError};
654 use crate::provenance::{LogRow, ProvenanceLog};
655 use crate::rate_limiter::RateLimiter;
656 use crate::source::FetchContext;
657 use crate::{ArxivId, CapabilityProfile, Doi, RateLimits, Ref};
658
659 const TEST_SESSION_ID: &str = "01J0000000000000000000TEST";
660
661 fn build_test_context(wiremock_host: &str) -> (TempDir, FetchContext) {
664 let td = TempDir::new().expect("tempdir");
665 let log_dir =
666 Utf8PathBuf::try_from(td.path().to_path_buf()).expect("temp dir path must be UTF-8");
667 let log_path = log_dir.join("test.jsonl");
668
669 let http = Arc::new(HttpClient::new_for_tests_allow_http("arxiv", wiremock_host));
670 let rate_limiter = Arc::new(RateLimiter::new(RateLimits::HARD_CODED));
671 let session_id = TEST_SESSION_ID.to_string();
672 let log = Arc::new(
673 ProvenanceLog::open(log_path, session_id.clone()).expect("provenance log opens"),
674 );
675
676 (
677 td,
678 FetchContext {
679 http,
680 rate_limiter,
681 log,
682 session_id,
683 cache_root: None,
684 },
685 )
686 }
687
688 fn read_rows(path: &camino::Utf8Path) -> Vec<LogRow> {
689 let raw = std::fs::read_to_string(path).expect("read log");
690 raw.lines()
691 .filter(|l| !l.is_empty())
692 .map(|l| serde_json::from_str::<LogRow>(l).expect("valid LogRow"))
693 .collect()
694 }
695
696 fn profile() -> CapabilityProfile {
697 CapabilityProfile::for_tests()
698 }
699
700 #[test]
705 fn arxiv_can_serve_returns_true_for_arxiv() {
706 let s = ArxivSource::new();
707 let id = ArxivId::parse("2401.12345").expect("valid id");
708 let r = Ref::Arxiv(id);
709 assert!(s.can_serve(&profile(), &r));
710 }
711
712 #[test]
713 fn production_metadata_url_uses_export_host_pdf_uses_arxiv() {
714 let s = ArxivSource::new();
718 let id = ArxivId::parse("1706.03762").expect("valid id");
719 let meta = s.metadata_url(&id).expect("meta url");
720 assert_eq!(meta.host_str(), Some("export.arxiv.org"));
721 assert_eq!(meta.path(), "/api/query");
722 let pdf = s.pdf_url(&id).expect("pdf url");
723 assert_eq!(pdf.host_str(), Some("arxiv.org"));
724 }
725
726 #[test]
727 fn with_base_shares_one_origin_for_both_legs() {
728 let s = ArxivSource::with_base("http://127.0.0.1:9999".parse().expect("url"));
731 let id = ArxivId::parse("2401.12345").expect("valid id");
732 assert_eq!(
733 s.metadata_url(&id).expect("meta").host_str(),
734 s.pdf_url(&id).expect("pdf").host_str()
735 );
736 }
737
738 #[test]
739 fn arxiv_can_serve_returns_false_for_doi() {
740 let s = ArxivSource::new();
741 let r = Ref::Doi(Doi("10.1234/example".to_string()));
742 assert!(!s.can_serve(&profile(), &r));
743 }
744
745 #[tokio::test]
750 async fn arxiv_fetch_new_style_id_returns_pdf_bytes() {
751 let server = MockServer::start().await;
752 let body = b"%PDF-1.7\n%fixture\n".to_vec();
753 Mock::given(method("GET"))
754 .and(path("/pdf/2401.12345.pdf"))
755 .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
756 .mount(&server)
757 .await;
758
759 let host = server
760 .uri()
761 .parse::<Url>()
762 .unwrap()
763 .host_str()
764 .unwrap()
765 .to_string();
766 let (_td, ctx) = build_test_context(&host);
767 let s = ArxivSource::with_base(server.uri().parse().unwrap());
768
769 let id = ArxivId::parse("2401.12345").unwrap();
770 let r = Ref::Arxiv(id);
771 let res = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
772
773 assert_eq!(res.source, "arxiv");
774 assert_eq!(res.license, "arxiv-default");
775 let bytes = res.pdf_bytes.expect("pdf bytes set");
776 assert!(
777 bytes.starts_with(b"%PDF-"),
778 "expected PDF magic prefix, got {:?}",
779 &bytes[..bytes.len().min(8)]
780 );
781 assert_eq!(&bytes[..], &body[..]);
782 }
783
784 #[tokio::test]
785 async fn arxiv_fetch_old_style_id_returns_pdf_bytes() {
786 let server = MockServer::start().await;
790 let body = b"%PDF-1.4\n%old-style fixture\n".to_vec();
791 Mock::given(method("GET"))
792 .and(path("/pdf/cond-mat/9501001.pdf"))
793 .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
794 .mount(&server)
795 .await;
796
797 let host = server
798 .uri()
799 .parse::<Url>()
800 .unwrap()
801 .host_str()
802 .unwrap()
803 .to_string();
804 let (_td, ctx) = build_test_context(&host);
805 let s = ArxivSource::with_base(server.uri().parse().unwrap());
806
807 let id = ArxivId::parse("cond-mat/9501001").expect("old-style id");
808 let r = Ref::Arxiv(id);
809 let res = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
810
811 let bytes = res.pdf_bytes.expect("pdf bytes set");
812 assert!(bytes.starts_with(b"%PDF-"));
813 assert_eq!(&bytes[..], &body[..]);
814 }
815
816 #[tokio::test]
821 async fn arxiv_fetch_with_doi_ref_errors_not_eligible() {
822 let server = MockServer::start().await;
823 let host = server
824 .uri()
825 .parse::<Url>()
826 .unwrap()
827 .host_str()
828 .unwrap()
829 .to_string();
830 let (_td, ctx) = build_test_context(&host);
831 let s = ArxivSource::with_base(server.uri().parse().unwrap());
832
833 let r = Ref::Doi(Doi("10.1234/example".to_string()));
834 let err = s
835 .fetch(&r, &profile(), &ctx)
836 .await
837 .expect_err("doi ref must not be eligible");
838 match err {
839 FetchError::NotEligible { source_key } => {
840 assert_eq!(source_key, "arxiv");
841 }
842 other => panic!("expected NotEligible, got {:?}", other),
843 }
844 }
845
846 #[tokio::test]
847 async fn arxiv_fetch_writes_log_row_with_arxiv_default_license() {
848 let server = MockServer::start().await;
849 let body = b"%PDF-1.7\n%log-row fixture\n".to_vec();
850 Mock::given(method("GET"))
851 .and(path("/pdf/2401.12345.pdf"))
852 .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
853 .mount(&server)
854 .await;
855 let host = server
856 .uri()
857 .parse::<Url>()
858 .unwrap()
859 .host_str()
860 .unwrap()
861 .to_string();
862 let (_td, ctx) = build_test_context(&host);
863 let log_path = ctx.log.path().to_path_buf();
865 let s = ArxivSource::with_base(server.uri().parse().unwrap());
866
867 let id = ArxivId::parse("2401.12345").unwrap();
868 let r = Ref::Arxiv(id);
869 let _ = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
870
871 let rows = read_rows(&log_path);
872 assert_eq!(rows.len(), 1, "exactly one fetch row expected");
873 let row = &rows[0];
874 assert_eq!(row.source.as_deref(), Some("arxiv"));
875 assert_eq!(row.ref_.as_deref(), Some("2401.12345"));
876 assert_eq!(row.license.as_deref(), Some("arxiv-default"));
877 assert_eq!(row.size_bytes, Some(body.len() as u64));
878 assert!(row.error_code.is_none());
879 }
880
881 #[tokio::test]
882 async fn arxiv_non_pdf_body_rejected() {
883 let server = MockServer::start().await;
887 Mock::given(method("GET"))
888 .and(path("/pdf/2401.12345.pdf"))
889 .respond_with(
890 ResponseTemplate::new(200).set_body_bytes(b"<html>not a pdf</html>".to_vec()),
891 )
892 .mount(&server)
893 .await;
894 let host = server
895 .uri()
896 .parse::<Url>()
897 .unwrap()
898 .host_str()
899 .unwrap()
900 .to_string();
901 let (_td, ctx) = build_test_context(&host);
902 let s = ArxivSource::with_base(server.uri().parse().unwrap());
903
904 let id = ArxivId::parse("2401.12345").unwrap();
905 let r = Ref::Arxiv(id);
906 let err = s
907 .fetch(&r, &profile(), &ctx)
908 .await
909 .expect_err("non-pdf body must be rejected");
910 match err {
911 FetchError::Http(HttpError::NotAPdf { got }) => {
912 assert_eq!(&got, b"<html");
913 }
914 other => panic!("expected FetchError::Http(NotAPdf), got {:?}", other),
915 }
916 }
917
918 #[tokio::test]
919 async fn arxiv_404_maps_to_http_error() {
920 let server = MockServer::start().await;
921 Mock::given(method("GET"))
922 .and(path("/pdf/2401.99999.pdf"))
923 .respond_with(ResponseTemplate::new(404))
924 .mount(&server)
925 .await;
926 let host = server
927 .uri()
928 .parse::<Url>()
929 .unwrap()
930 .host_str()
931 .unwrap()
932 .to_string();
933 let (_td, ctx) = build_test_context(&host);
934 let s = ArxivSource::with_base(server.uri().parse().unwrap());
935
936 let id = ArxivId::parse("2401.99999").unwrap();
937 let r = Ref::Arxiv(id);
938 let err = s
939 .fetch(&r, &profile(), &ctx)
940 .await
941 .expect_err("404 must surface");
942 match err {
943 FetchError::Http(HttpError::HttpStatus { status, .. }) => {
944 assert_eq!(status, 404);
945 }
946 other => panic!("expected FetchError::Http(HttpStatus), got {:?}", other),
947 }
948 }
949
950 const SAMPLE_ATOM_FEED: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
957<feed xmlns="http://www.w3.org/2005/Atom">
958 <entry>
959 <id>http://arxiv.org/abs/2401.12345v1</id>
960 <updated>2024-02-01T00:00:00Z</updated>
961 <published>2024-01-15T00:00:00Z</published>
962 <title>Example arXiv Paper Title</title>
963 <summary>This is an example abstract.</summary>
964 <author>
965 <name>Jane Doe</name>
966 </author>
967 <author>
968 <name>John Roe</name>
969 </author>
970 <category term="cs.LG" scheme="http://arxiv.org/schemas/atom"/>
971 <category term="stat.ML" scheme="http://arxiv.org/schemas/atom"/>
972 </entry>
973</feed>"#;
974
975 #[test]
976 fn parse_atom_feed_extracts_all_fields() {
977 let v = parse_atom_feed(SAMPLE_ATOM_FEED.as_bytes()).expect("Atom parses");
978 assert_eq!(v["title"], serde_json::json!("Example arXiv Paper Title"));
979 assert_eq!(
980 v["abstract"],
981 serde_json::json!("This is an example abstract.")
982 );
983 assert_eq!(v["authors"], serde_json::json!(["Jane Doe", "John Roe"]));
984 assert_eq!(v["published"], serde_json::json!("2024-01-15T00:00:00Z"));
985 assert_eq!(v["updated"], serde_json::json!("2024-02-01T00:00:00Z"));
986 assert_eq!(v["categories"], serde_json::json!(["cs.LG", "stat.ML"]));
987 }
988
989 #[test]
990 fn parse_atom_feed_empty_feed_is_not_found() {
991 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
995<feed xmlns="http://www.w3.org/2005/Atom"></feed>"#;
996 let err = parse_atom_feed(xml.as_bytes()).expect_err("empty feed must error");
997 match err {
998 FetchError::NotFound { hint } => {
999 assert!(
1000 hint.contains("entry"),
1001 "expected mention of <entry>; got {hint}"
1002 );
1003 }
1004 other => panic!("expected NotFound, got {other:?}"),
1005 }
1006 }
1007
1008 #[test]
1009 fn parse_atom_feed_captures_published_doi_and_journal_ref() {
1010 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1015<feed xmlns="http://www.w3.org/2005/Atom" xmlns:arxiv="http://arxiv.org/schemas/atom">
1016 <entry>
1017 <id>http://arxiv.org/abs/2101.54321v2</id>
1018 <title>Published Later</title>
1019 <arxiv:doi>10.1103/PhysRevLett.130.200601</arxiv:doi>
1020 <arxiv:journal_ref>Phys. Rev. Lett. 130, 200601 (2023)</arxiv:journal_ref>
1021 </entry>
1022</feed>"#;
1023 let v = parse_atom_feed(xml.as_bytes()).expect("parses");
1024 assert_eq!(
1025 v["doi"],
1026 serde_json::json!("10.1103/PhysRevLett.130.200601")
1027 );
1028 assert_eq!(
1029 v["journal_ref"],
1030 serde_json::json!("Phys. Rev. Lett. 130, 200601 (2023)")
1031 );
1032 }
1033
1034 #[test]
1035 fn parse_atom_feed_omits_doi_when_absent() {
1036 let v = parse_atom_feed(SAMPLE_ATOM_FEED.as_bytes()).expect("parses");
1039 let obj = v.as_object().expect("object");
1040 assert!(!obj.contains_key("doi"), "doi must be omitted: {obj:?}");
1041 assert!(
1042 !obj.contains_key("journal_ref"),
1043 "journal_ref must be omitted: {obj:?}"
1044 );
1045 }
1046
1047 #[test]
1048 fn parse_atom_feed_journal_ref_only_without_doi() {
1049 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1052<feed xmlns="http://www.w3.org/2005/Atom" xmlns:arxiv="http://arxiv.org/schemas/atom">
1053 <entry>
1054 <id>http://arxiv.org/abs/2101.00001v1</id>
1055 <title>Journal Ref Only</title>
1056 <arxiv:journal_ref>J. Stat. Mech. (2021) 013203</arxiv:journal_ref>
1057 </entry>
1058</feed>"#;
1059 let v = parse_atom_feed(xml.as_bytes()).expect("parses");
1060 let obj = v.as_object().expect("object");
1061 assert!(!obj.contains_key("doi"), "doi must be omitted: {obj:?}");
1062 assert_eq!(
1063 obj.get("journal_ref").and_then(Value::as_str),
1064 Some("J. Stat. Mech. (2021) 013203")
1065 );
1066 }
1067
1068 #[test]
1069 fn parse_atom_feed_whitespace_doi_is_omitted() {
1070 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1073<feed xmlns="http://www.w3.org/2005/Atom" xmlns:arxiv="http://arxiv.org/schemas/atom">
1074 <entry>
1075 <id>http://arxiv.org/abs/2101.00002v1</id>
1076 <title>Blank DOI</title>
1077 <arxiv:doi> </arxiv:doi>
1078 </entry>
1079</feed>"#;
1080 let v = parse_atom_feed(xml.as_bytes()).expect("parses");
1081 assert!(
1082 !v.as_object().expect("object").contains_key("doi"),
1083 "whitespace-only doi must be omitted: {v:?}"
1084 );
1085 }
1086
1087 #[test]
1088 fn parse_atom_feed_omits_missing_optional_fields() {
1089 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1093<feed xmlns="http://www.w3.org/2005/Atom">
1094 <entry>
1095 <id>http://arxiv.org/abs/2401.00001v1</id>
1096 <title>Minimal Entry</title>
1097 </entry>
1098</feed>"#;
1099 let v = parse_atom_feed(xml.as_bytes()).expect("parses");
1100 let obj = v.as_object().expect("object");
1101 assert_eq!(
1102 obj.get("title").and_then(Value::as_str),
1103 Some("Minimal Entry")
1104 );
1105 assert!(
1106 !obj.contains_key("abstract"),
1107 "abstract should be omitted: {obj:?}"
1108 );
1109 assert!(
1110 !obj.contains_key("authors"),
1111 "authors should be omitted: {obj:?}"
1112 );
1113 assert!(
1114 !obj.contains_key("categories"),
1115 "categories should be omitted: {obj:?}"
1116 );
1117 }
1118
1119 #[tokio::test]
1124 async fn arxiv_fetch_metadata_only_returns_atom_metadata() {
1125 let server = MockServer::start().await;
1126 Mock::given(method("GET"))
1127 .and(path("/api/query"))
1128 .respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_ATOM_FEED))
1129 .mount(&server)
1130 .await;
1131 let host = server
1132 .uri()
1133 .parse::<Url>()
1134 .unwrap()
1135 .host_str()
1136 .unwrap()
1137 .to_string();
1138 let (_td, ctx) = build_test_context(&host);
1139 let s = ArxivSource::with_base(server.uri().parse().unwrap());
1140 let id = ArxivId::parse("2401.12345").unwrap();
1141
1142 let meta = s
1143 .fetch_metadata_only(&id, &ctx)
1144 .await
1145 .expect("metadata_only ok");
1146 assert_eq!(
1147 meta["title"],
1148 serde_json::json!("Example arXiv Paper Title")
1149 );
1150 assert_eq!(meta["authors"], serde_json::json!(["Jane Doe", "John Roe"]));
1151 }
1152
1153 #[tokio::test]
1154 async fn arxiv_fetch_populates_metadata_json_when_atom_endpoint_mocked() {
1155 let server = MockServer::start().await;
1158 Mock::given(method("GET"))
1159 .and(path("/api/query"))
1160 .respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_ATOM_FEED))
1161 .mount(&server)
1162 .await;
1163 Mock::given(method("GET"))
1164 .and(path("/pdf/2401.12345.pdf"))
1165 .respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.7\n%fix\n".to_vec()))
1166 .mount(&server)
1167 .await;
1168 let host = server
1169 .uri()
1170 .parse::<Url>()
1171 .unwrap()
1172 .host_str()
1173 .unwrap()
1174 .to_string();
1175 let (_td, ctx) = build_test_context(&host);
1176 let s = ArxivSource::with_base(server.uri().parse().unwrap());
1177 let id = ArxivId::parse("2401.12345").unwrap();
1178 let r = Ref::Arxiv(id);
1179
1180 let res = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
1181 let meta = res.metadata_json.expect("metadata_json populated");
1182 assert_eq!(
1183 meta["title"],
1184 serde_json::json!("Example arXiv Paper Title")
1185 );
1186 }
1187
1188 #[tokio::test]
1189 async fn arxiv_fetch_atom_failure_falls_back_to_pdf_only() {
1190 let server = MockServer::start().await;
1194 Mock::given(method("GET"))
1195 .and(path("/pdf/2401.12345.pdf"))
1196 .respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.7\nx".to_vec()))
1197 .mount(&server)
1198 .await;
1199 let host = server
1200 .uri()
1201 .parse::<Url>()
1202 .unwrap()
1203 .host_str()
1204 .unwrap()
1205 .to_string();
1206 let (_td, ctx) = build_test_context(&host);
1207 let s = ArxivSource::with_base(server.uri().parse().unwrap());
1208 let id = ArxivId::parse("2401.12345").unwrap();
1209 let r = Ref::Arxiv(id);
1210
1211 let res = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
1212 assert!(res.metadata_json.is_none());
1213 assert!(res.pdf_bytes.is_some());
1214 }
1215}