Skip to main content

aspero_core/
work.rs

1//! The minimal, provider-neutral description of a scholarly work.
2//!
3//! Shaped after CSL-JSON so export and citation formatting stay cheap, but it
4//! is not CSL-JSON itself: identifiers live on [`SourceRecord`](crate::SourceRecord),
5//! and anything source-specific stays in the provider's raw response.
6//!
7//! Every field is optional, because no provider fills everything. The structs
8//! are `#[non_exhaustive]`: build them from `Default` and assign fields, so
9//! adding a field later is not a breaking change.
10
11use std::fmt;
12use std::str::FromStr;
13
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[non_exhaustive]
18pub struct Work {
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub title: Option<String>,
21    /// In byline order. Empty means "not provided", not "anonymous".
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub authors: Vec<Person>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub issued: Option<PartialDate>,
26    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
27    pub work_type: Option<WorkType>,
28    /// Journal, proceedings or book title.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub container_title: Option<String>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub volume: Option<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub issue: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub page: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub publisher: Option<String>,
39    #[serde(default, rename = "abstract", skip_serializing_if = "Option::is_none")]
40    pub abstract_text: Option<String>,
41    /// Landing page for the work.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub url: Option<String>,
44    /// As counted by the providing source; counts differ between sources.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub cited_by_count: Option<u64>,
47    /// Retractions, corrections and other editorial notices about the work.
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub updates: Vec<Update>,
50    /// When the work is itself a notice, the works it updates: what an
51    /// erratum corrects or a retraction notice retracts.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub update_to: Vec<UpdateTo>,
54    /// Years of other editions or versions of the same work, when the
55    /// provider knows them (a book's later editions). A citation of any of
56    /// them is a citation of this work.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub other_years: Vec<i16>,
59}
60
61/// An editorial notice about a work: retraction, correction, expression of
62/// concern, withdrawal.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[non_exhaustive]
65pub struct Update {
66    pub kind: UpdateKind,
67    /// The notice's own identifier (usually a DOI), if known.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub notice: Option<crate::id::WorkId>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub date: Option<PartialDate>,
72    /// Who reported it: "retraction-watch", "publisher", "openalex", ….
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub source: Option<String>,
75}
76
77impl Update {
78    pub fn new(kind: UpdateKind) -> Self {
79        Update {
80            kind,
81            notice: None,
82            date: None,
83            source: None,
84        }
85    }
86}
87
88/// A notice's link to the work it updates (Crossref's `update-to`): the
89/// other direction of an [`Update`].
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[non_exhaustive]
92pub struct UpdateTo {
93    pub kind: UpdateKind,
94    /// The updated work.
95    pub work: crate::id::WorkId,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub date: Option<PartialDate>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub source: Option<String>,
100}
101
102impl UpdateTo {
103    pub fn new(kind: UpdateKind, work: crate::id::WorkId) -> Self {
104        UpdateTo {
105            kind,
106            work,
107            date: None,
108            source: None,
109        }
110    }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Hash)]
114#[non_exhaustive]
115pub enum UpdateKind {
116    Retraction,
117    Withdrawal,
118    ExpressionOfConcern,
119    Correction,
120    Other(String),
121}
122
123impl UpdateKind {
124    pub fn as_str(&self) -> &str {
125        match self {
126            UpdateKind::Retraction => "retraction",
127            UpdateKind::Withdrawal => "withdrawal",
128            UpdateKind::ExpressionOfConcern => "expression_of_concern",
129            UpdateKind::Correction => "correction",
130            UpdateKind::Other(s) => s,
131        }
132    }
133
134    /// From Crossref/Retraction Watch type names (`retraction`,
135    /// `expression_of_concern`, `erratum`, …); never fails.
136    pub fn parse(s: &str) -> Self {
137        match s.to_ascii_lowercase().replace('-', "_").as_str() {
138            "retraction" | "partial_retraction" => UpdateKind::Retraction,
139            "withdrawal" | "removal" => UpdateKind::Withdrawal,
140            "expression_of_concern" => UpdateKind::ExpressionOfConcern,
141            "correction" | "erratum" | "corrigendum" | "addendum" => UpdateKind::Correction,
142            _ => UpdateKind::Other(s.to_owned()),
143        }
144    }
145
146    /// Whether citing the work is unsafe: retracted or withdrawn.
147    pub fn is_retraction(&self) -> bool {
148        matches!(self, UpdateKind::Retraction | UpdateKind::Withdrawal)
149    }
150}
151
152impl Serialize for UpdateKind {
153    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
154        s.serialize_str(self.as_str())
155    }
156}
157
158impl<'de> Deserialize<'de> for UpdateKind {
159    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
160        Ok(UpdateKind::parse(&String::deserialize(d)?))
161    }
162}
163
164impl Work {
165    /// The first retraction or withdrawal notice, if any.
166    pub fn retraction(&self) -> Option<&Update> {
167        self.updates.iter().find(|u| u.kind.is_retraction())
168    }
169}
170
171#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
172#[non_exhaustive]
173pub struct Person {
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub given: Option<String>,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub family: Option<String>,
178    /// A name that was not split into given/family, or an organisation.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub literal: Option<String>,
181    /// Bare ORCID iD, e.g. `0000-0002-1825-0097`.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub orcid: Option<String>,
184}
185
186impl Person {
187    pub fn new(given: impl Into<String>, family: impl Into<String>) -> Self {
188        Person {
189            given: Some(given.into()),
190            family: Some(family.into()),
191            ..Person::default()
192        }
193    }
194
195    pub fn literal(name: impl Into<String>) -> Self {
196        Person {
197            literal: Some(name.into()),
198            ..Person::default()
199        }
200    }
201
202    /// The best single-string name: "Given Family", or the literal.
203    pub fn display_name(&self) -> Option<String> {
204        match (&self.given, &self.family, &self.literal) {
205            (Some(g), Some(f), _) => Some(format!("{g} {f}")),
206            (None, Some(f), _) => Some(f.clone()),
207            (_, _, Some(l)) => Some(l.clone()),
208            (Some(g), None, None) => Some(g.clone()),
209            (None, None, None) => None,
210        }
211    }
212}
213
214/// Removes an "Abstract" heading that some sources leave at the start of
215/// abstract text (JATS `<title>Abstract</title>`, kept as a word by
216/// OpenAlex). Only a standalone heading: "Abstraction is…" is untouched.
217pub fn tidy_abstract(text: &str) -> String {
218    let text = text.trim();
219    for heading in ["Abstract", "ABSTRACT"] {
220        if let Some(rest) = text.strip_prefix(heading)
221            && rest.starts_with(|c: char| c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—'))
222        {
223            return rest
224                .trim_start_matches(|c: char| {
225                    c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—')
226                })
227                .to_owned();
228        }
229    }
230    text.to_owned()
231}
232
233/// A date known to year, month or day precision. Serialises as `2017`,
234/// `2017-06` or `2017-06-12`.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
236pub struct PartialDate {
237    year: i16,
238    month: Option<u8>,
239    day: Option<u8>,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
243#[error("invalid date {0:?}: expected YYYY, YYYY-MM or YYYY-MM-DD")]
244pub struct DateError(String);
245
246impl PartialDate {
247    pub fn from_year(year: i16) -> Self {
248        PartialDate {
249            year,
250            month: None,
251            day: None,
252        }
253    }
254
255    /// `None` if the month or day is out of range for the calendar.
256    pub fn new(year: i16, month: Option<u8>, day: Option<u8>) -> Option<Self> {
257        match (month, day) {
258            (None, Some(_)) => return None,
259            (Some(m), None) if !(1..=12).contains(&m) => return None,
260            (Some(m), Some(d)) => {
261                jiff::civil::Date::new(year, m as i8, d as i8).ok()?;
262            }
263            _ => {}
264        }
265        Some(PartialDate { year, month, day })
266    }
267
268    pub fn year(&self) -> i16 {
269        self.year
270    }
271
272    pub fn month(&self) -> Option<u8> {
273        self.month
274    }
275
276    pub fn day(&self) -> Option<u8> {
277        self.day
278    }
279}
280
281impl FromStr for PartialDate {
282    type Err = DateError;
283
284    fn from_str(s: &str) -> Result<Self, DateError> {
285        let err = || DateError(s.to_owned());
286        let mut parts = s.trim().splitn(3, '-');
287        let mut num = |len: usize| -> Result<Option<u16>, DateError> {
288            match parts.next() {
289                None => Ok(None),
290                Some(p) if p.len() == len && p.bytes().all(|b| b.is_ascii_digit()) => {
291                    Ok(Some(p.parse().map_err(|_| err())?))
292                }
293                Some(_) => Err(err()),
294            }
295        };
296        let year = num(4)?.ok_or_else(err)?;
297        let month = num(2)?;
298        let day = num(2)?;
299        PartialDate::new(year as i16, month.map(|m| m as u8), day.map(|d| d as u8)).ok_or_else(err)
300    }
301}
302
303impl fmt::Display for PartialDate {
304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305        write!(f, "{:04}", self.year)?;
306        if let Some(m) = self.month {
307            write!(f, "-{m:02}")?;
308        }
309        if let Some(d) = self.day {
310            write!(f, "-{d:02}")?;
311        }
312        Ok(())
313    }
314}
315
316/// Kind of work, as CSL types. Unknown upstream types are kept verbatim in
317/// `Other` rather than dropped.
318#[derive(Debug, Clone, PartialEq, Eq, Hash)]
319#[non_exhaustive]
320pub enum WorkType {
321    JournalArticle,
322    ConferencePaper,
323    /// CSL `article`: preprints and other unreviewed articles.
324    Preprint,
325    Book,
326    Chapter,
327    Thesis,
328    Report,
329    Dataset,
330    Software,
331    Review,
332    Other(String),
333}
334
335impl WorkType {
336    /// The CSL type name.
337    pub fn as_csl(&self) -> &str {
338        match self {
339            WorkType::JournalArticle => "article-journal",
340            WorkType::ConferencePaper => "paper-conference",
341            WorkType::Preprint => "article",
342            WorkType::Book => "book",
343            WorkType::Chapter => "chapter",
344            WorkType::Thesis => "thesis",
345            WorkType::Report => "report",
346            WorkType::Dataset => "dataset",
347            WorkType::Software => "software",
348            WorkType::Review => "review",
349            WorkType::Other(s) => s,
350        }
351    }
352
353    /// Inverse of [`WorkType::as_csl`]; never fails.
354    pub fn from_csl(s: &str) -> Self {
355        match s {
356            "article-journal" => WorkType::JournalArticle,
357            "paper-conference" => WorkType::ConferencePaper,
358            "article" => WorkType::Preprint,
359            "book" => WorkType::Book,
360            "chapter" => WorkType::Chapter,
361            "thesis" => WorkType::Thesis,
362            "report" => WorkType::Report,
363            "dataset" => WorkType::Dataset,
364            "software" => WorkType::Software,
365            "review" => WorkType::Review,
366            other => WorkType::Other(other.to_owned()),
367        }
368    }
369}
370
371impl Serialize for WorkType {
372    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
373        s.serialize_str(self.as_csl())
374    }
375}
376
377impl<'de> Deserialize<'de> for WorkType {
378    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
379        Ok(WorkType::from_csl(&String::deserialize(d)?))
380    }
381}
382
383impl Serialize for PartialDate {
384    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
385        s.collect_str(self)
386    }
387}
388
389impl<'de> Deserialize<'de> for PartialDate {
390    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
391        String::deserialize(d)?
392            .parse()
393            .map_err(serde::de::Error::custom)
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn partial_dates_parse_validate_and_round_trip() {
403        for s in ["2017", "2017-06", "2017-06-12", "2024-02-29"] {
404            assert_eq!(s.parse::<PartialDate>().unwrap().to_string(), s);
405        }
406        for s in [
407            "17",
408            "2017-6",
409            "2017-13",
410            "2017-06-31",
411            "2023-02-29",
412            "2017--01",
413            "x",
414        ] {
415            assert!(s.parse::<PartialDate>().is_err(), "{s:?}");
416        }
417        assert_eq!(PartialDate::new(2017, None, Some(3)), None);
418    }
419
420    #[test]
421    fn work_type_keeps_unknown_values() {
422        assert_eq!(
423            WorkType::from_csl("article-journal"),
424            WorkType::JournalArticle
425        );
426        let other = WorkType::from_csl("musical-score");
427        assert_eq!(other.as_csl(), "musical-score");
428    }
429
430    #[test]
431    fn work_serialises_sparsely_with_csl_names() {
432        let w = Work {
433            title: Some("Attention Is All You Need".into()),
434            issued: Some(PartialDate::from_year(2017)),
435            work_type: Some(WorkType::ConferencePaper),
436            authors: vec![Person::new("Ashish", "Vaswani")],
437            ..Work::default()
438        };
439        let json = serde_json::to_value(&w).unwrap();
440        assert_eq!(
441            json,
442            serde_json::json!({
443                "title": "Attention Is All You Need",
444                "authors": [{"given": "Ashish", "family": "Vaswani"}],
445                "issued": "2017",
446                "type": "paper-conference",
447            })
448        );
449        assert_eq!(serde_json::from_value::<Work>(json).unwrap(), w);
450    }
451
452    #[test]
453    fn tidy_abstract_drops_only_a_standalone_heading() {
454        assert_eq!(
455            tidy_abstract("Abstract Proteins are essential"),
456            "Proteins are essential"
457        );
458        assert_eq!(tidy_abstract("ABSTRACT: We show"), "We show");
459        assert_eq!(
460            tidy_abstract("  Abstraction is useful"),
461            "Abstraction is useful"
462        );
463        assert_eq!(tidy_abstract("We show"), "We show");
464    }
465
466    #[test]
467    fn person_display_name_prefers_split_names() {
468        assert_eq!(
469            Person::new("Ada", "Lovelace").display_name().unwrap(),
470            "Ada Lovelace"
471        );
472        assert_eq!(Person::literal("CERN").display_name().unwrap(), "CERN");
473        assert_eq!(Person::default().display_name(), None);
474    }
475}