1use 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 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub url: Option<String>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub cited_by_count: Option<u64>,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub updates: Vec<Update>,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub update_to: Vec<UpdateTo>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub open_access: Option<OpenAccess>,
57 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub other_years: Vec<i16>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[non_exhaustive]
68pub struct Update {
69 pub kind: UpdateKind,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub notice: Option<crate::id::WorkId>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub date: Option<PartialDate>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub source: Option<String>,
78}
79
80impl Update {
81 pub fn new(kind: UpdateKind) -> Self {
82 Update {
83 kind,
84 notice: None,
85 date: None,
86 source: None,
87 }
88 }
89}
90
91#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
94#[non_exhaustive]
95pub struct OpenAccess {
96 pub is_oa: bool,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub status: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub pdf_url: Option<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub url: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub license: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub version: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub source: Option<String>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[non_exhaustive]
124pub struct UpdateTo {
125 pub kind: UpdateKind,
126 pub work: crate::id::WorkId,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub date: Option<PartialDate>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub source: Option<String>,
132}
133
134impl UpdateTo {
135 pub fn new(kind: UpdateKind, work: crate::id::WorkId) -> Self {
136 UpdateTo {
137 kind,
138 work,
139 date: None,
140 source: None,
141 }
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Hash)]
146#[non_exhaustive]
147pub enum UpdateKind {
148 Retraction,
149 Withdrawal,
150 ExpressionOfConcern,
151 Correction,
152 NewVersion,
156 Other(String),
157}
158
159impl UpdateKind {
160 pub fn as_str(&self) -> &str {
161 match self {
162 UpdateKind::Retraction => "retraction",
163 UpdateKind::Withdrawal => "withdrawal",
164 UpdateKind::ExpressionOfConcern => "expression_of_concern",
165 UpdateKind::Correction => "correction",
166 UpdateKind::NewVersion => "new_version",
167 UpdateKind::Other(s) => s,
168 }
169 }
170
171 pub fn parse(s: &str) -> Self {
174 match s.to_ascii_lowercase().replace('-', "_").as_str() {
175 "retraction" | "partial_retraction" => UpdateKind::Retraction,
176 "withdrawal" | "removal" => UpdateKind::Withdrawal,
177 "expression_of_concern" => UpdateKind::ExpressionOfConcern,
178 "correction" | "erratum" | "corrigendum" | "addendum" => UpdateKind::Correction,
179 "new_version" | "new_edition" => UpdateKind::NewVersion,
180 _ => UpdateKind::Other(s.to_owned()),
181 }
182 }
183
184 pub fn is_retraction(&self) -> bool {
186 matches!(self, UpdateKind::Retraction | UpdateKind::Withdrawal)
187 }
188}
189
190impl Serialize for UpdateKind {
191 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
192 s.serialize_str(self.as_str())
193 }
194}
195
196impl<'de> Deserialize<'de> for UpdateKind {
197 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
198 Ok(UpdateKind::parse(&String::deserialize(d)?))
199 }
200}
201
202impl Work {
203 pub fn retraction(&self) -> Option<&Update> {
205 self.updates.iter().find(|u| u.kind.is_retraction())
206 }
207
208 pub fn newer_version(&self) -> Option<&crate::id::WorkId> {
210 self.updates
211 .iter()
212 .filter(|u| u.kind == UpdateKind::NewVersion)
213 .find_map(|u| u.notice.as_ref())
214 }
215}
216
217#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
218#[non_exhaustive]
219pub struct Person {
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub given: Option<String>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub family: Option<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub literal: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub orcid: Option<String>,
230}
231
232impl Person {
233 pub fn new(given: impl Into<String>, family: impl Into<String>) -> Self {
234 Person {
235 given: Some(given.into()),
236 family: Some(family.into()),
237 ..Person::default()
238 }
239 }
240
241 pub fn literal(name: impl Into<String>) -> Self {
242 Person {
243 literal: Some(name.into()),
244 ..Person::default()
245 }
246 }
247
248 pub fn display_name(&self) -> Option<String> {
250 match (&self.given, &self.family, &self.literal) {
251 (Some(g), Some(f), _) => Some(format!("{g} {f}")),
252 (None, Some(f), _) => Some(f.clone()),
253 (_, _, Some(l)) => Some(l.clone()),
254 (Some(g), None, None) => Some(g.clone()),
255 (None, None, None) => None,
256 }
257 }
258}
259
260pub fn tidy_abstract(text: &str) -> String {
264 let text = text.trim();
265 for heading in ["Abstract", "ABSTRACT"] {
266 if let Some(rest) = text.strip_prefix(heading)
267 && rest.starts_with(|c: char| c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—'))
268 {
269 return rest
270 .trim_start_matches(|c: char| {
271 c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—')
272 })
273 .to_owned();
274 }
275 }
276 text.to_owned()
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
282pub struct PartialDate {
283 year: i16,
284 month: Option<u8>,
285 day: Option<u8>,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
289#[error("invalid date {0:?}: expected YYYY, YYYY-MM or YYYY-MM-DD")]
290pub struct DateError(String);
291
292impl PartialDate {
293 pub fn from_year(year: i16) -> Self {
294 PartialDate {
295 year,
296 month: None,
297 day: None,
298 }
299 }
300
301 pub fn new(year: i16, month: Option<u8>, day: Option<u8>) -> Option<Self> {
303 match (month, day) {
304 (None, Some(_)) => return None,
305 (Some(m), None) if !(1..=12).contains(&m) => return None,
306 (Some(m), Some(d)) => {
307 jiff::civil::Date::new(year, m as i8, d as i8).ok()?;
308 }
309 _ => {}
310 }
311 Some(PartialDate { year, month, day })
312 }
313
314 pub fn year(&self) -> i16 {
315 self.year
316 }
317
318 pub fn month(&self) -> Option<u8> {
319 self.month
320 }
321
322 pub fn day(&self) -> Option<u8> {
323 self.day
324 }
325}
326
327impl FromStr for PartialDate {
328 type Err = DateError;
329
330 fn from_str(s: &str) -> Result<Self, DateError> {
331 let err = || DateError(s.to_owned());
332 let mut parts = s.trim().splitn(3, '-');
333 let mut num = |len: usize| -> Result<Option<u16>, DateError> {
334 match parts.next() {
335 None => Ok(None),
336 Some(p) if p.len() == len && p.bytes().all(|b| b.is_ascii_digit()) => {
337 Ok(Some(p.parse().map_err(|_| err())?))
338 }
339 Some(_) => Err(err()),
340 }
341 };
342 let year = num(4)?.ok_or_else(err)?;
343 let month = num(2)?;
344 let day = num(2)?;
345 PartialDate::new(year as i16, month.map(|m| m as u8), day.map(|d| d as u8)).ok_or_else(err)
346 }
347}
348
349impl fmt::Display for PartialDate {
350 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351 write!(f, "{:04}", self.year)?;
352 if let Some(m) = self.month {
353 write!(f, "-{m:02}")?;
354 }
355 if let Some(d) = self.day {
356 write!(f, "-{d:02}")?;
357 }
358 Ok(())
359 }
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Hash)]
365#[non_exhaustive]
366pub enum WorkType {
367 JournalArticle,
368 ConferencePaper,
369 Preprint,
371 Book,
372 Chapter,
373 Thesis,
374 Report,
375 Dataset,
376 Software,
377 Review,
378 Other(String),
379}
380
381impl WorkType {
382 pub fn as_csl(&self) -> &str {
384 match self {
385 WorkType::JournalArticle => "article-journal",
386 WorkType::ConferencePaper => "paper-conference",
387 WorkType::Preprint => "article",
388 WorkType::Book => "book",
389 WorkType::Chapter => "chapter",
390 WorkType::Thesis => "thesis",
391 WorkType::Report => "report",
392 WorkType::Dataset => "dataset",
393 WorkType::Software => "software",
394 WorkType::Review => "review",
395 WorkType::Other(s) => s,
396 }
397 }
398
399 pub fn from_csl(s: &str) -> Self {
401 match s {
402 "article-journal" => WorkType::JournalArticle,
403 "paper-conference" => WorkType::ConferencePaper,
404 "article" => WorkType::Preprint,
405 "book" => WorkType::Book,
406 "chapter" => WorkType::Chapter,
407 "thesis" => WorkType::Thesis,
408 "report" => WorkType::Report,
409 "dataset" => WorkType::Dataset,
410 "software" => WorkType::Software,
411 "review" => WorkType::Review,
412 other => WorkType::Other(other.to_owned()),
413 }
414 }
415}
416
417impl Serialize for WorkType {
418 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
419 s.serialize_str(self.as_csl())
420 }
421}
422
423impl<'de> Deserialize<'de> for WorkType {
424 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
425 Ok(WorkType::from_csl(&String::deserialize(d)?))
426 }
427}
428
429impl Serialize for PartialDate {
430 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
431 s.collect_str(self)
432 }
433}
434
435impl<'de> Deserialize<'de> for PartialDate {
436 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
437 String::deserialize(d)?
438 .parse()
439 .map_err(serde::de::Error::custom)
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 #[test]
448 fn partial_dates_parse_validate_and_round_trip() {
449 for s in ["2017", "2017-06", "2017-06-12", "2024-02-29"] {
450 assert_eq!(s.parse::<PartialDate>().unwrap().to_string(), s);
451 }
452 for s in [
453 "17",
454 "2017-6",
455 "2017-13",
456 "2017-06-31",
457 "2023-02-29",
458 "2017--01",
459 "x",
460 ] {
461 assert!(s.parse::<PartialDate>().is_err(), "{s:?}");
462 }
463 assert_eq!(PartialDate::new(2017, None, Some(3)), None);
464 }
465
466 #[test]
467 fn work_type_keeps_unknown_values() {
468 assert_eq!(
469 WorkType::from_csl("article-journal"),
470 WorkType::JournalArticle
471 );
472 let other = WorkType::from_csl("musical-score");
473 assert_eq!(other.as_csl(), "musical-score");
474 }
475
476 #[test]
477 fn work_serialises_sparsely_with_csl_names() {
478 let w = Work {
479 title: Some("Attention Is All You Need".into()),
480 issued: Some(PartialDate::from_year(2017)),
481 work_type: Some(WorkType::ConferencePaper),
482 authors: vec![Person::new("Ashish", "Vaswani")],
483 ..Work::default()
484 };
485 let json = serde_json::to_value(&w).unwrap();
486 assert_eq!(
487 json,
488 serde_json::json!({
489 "title": "Attention Is All You Need",
490 "authors": [{"given": "Ashish", "family": "Vaswani"}],
491 "issued": "2017",
492 "type": "paper-conference",
493 })
494 );
495 assert_eq!(serde_json::from_value::<Work>(json).unwrap(), w);
496 }
497
498 #[test]
499 fn tidy_abstract_drops_only_a_standalone_heading() {
500 assert_eq!(
501 tidy_abstract("Abstract Proteins are essential"),
502 "Proteins are essential"
503 );
504 assert_eq!(tidy_abstract("ABSTRACT: We show"), "We show");
505 assert_eq!(
506 tidy_abstract(" Abstraction is useful"),
507 "Abstraction is useful"
508 );
509 assert_eq!(tidy_abstract("We show"), "We show");
510 }
511
512 #[test]
513 fn person_display_name_prefers_split_names() {
514 assert_eq!(
515 Person::new("Ada", "Lovelace").display_name().unwrap(),
516 "Ada Lovelace"
517 );
518 assert_eq!(Person::literal("CERN").display_name().unwrap(), "CERN");
519 assert_eq!(Person::default().display_name(), None);
520 }
521}