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 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub deposited: Option<PartialDate>,
83}
84
85impl Update {
86 pub fn new(kind: UpdateKind) -> Self {
87 Update {
88 kind,
89 notice: None,
90 date: None,
91 source: None,
92 deposited: None,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
100#[non_exhaustive]
101pub struct OpenAccess {
102 pub is_oa: bool,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub status: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub pdf_url: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub url: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub license: Option<String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub version: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub source: Option<String>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[non_exhaustive]
130pub struct UpdateTo {
131 pub kind: UpdateKind,
132 pub work: crate::id::WorkId,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub date: Option<PartialDate>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub source: Option<String>,
138}
139
140impl UpdateTo {
141 pub fn new(kind: UpdateKind, work: crate::id::WorkId) -> Self {
142 UpdateTo {
143 kind,
144 work,
145 date: None,
146 source: None,
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Hash)]
152#[non_exhaustive]
153pub enum UpdateKind {
154 Retraction,
155 Withdrawal,
156 ExpressionOfConcern,
157 Correction,
158 NewVersion,
162 Other(String),
163}
164
165impl UpdateKind {
166 pub fn as_str(&self) -> &str {
167 match self {
168 UpdateKind::Retraction => "retraction",
169 UpdateKind::Withdrawal => "withdrawal",
170 UpdateKind::ExpressionOfConcern => "expression_of_concern",
171 UpdateKind::Correction => "correction",
172 UpdateKind::NewVersion => "new_version",
173 UpdateKind::Other(s) => s,
174 }
175 }
176
177 pub fn parse(s: &str) -> Self {
180 match s.to_ascii_lowercase().replace('-', "_").as_str() {
181 "retraction" | "partial_retraction" => UpdateKind::Retraction,
182 "withdrawal" | "removal" => UpdateKind::Withdrawal,
183 "expression_of_concern" => UpdateKind::ExpressionOfConcern,
184 "correction" | "erratum" | "corrigendum" | "addendum" => UpdateKind::Correction,
185 "new_version" | "new_edition" => UpdateKind::NewVersion,
186 _ => UpdateKind::Other(s.to_owned()),
187 }
188 }
189
190 pub fn is_retraction(&self) -> bool {
192 matches!(self, UpdateKind::Retraction | UpdateKind::Withdrawal)
193 }
194}
195
196impl Serialize for UpdateKind {
197 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
198 s.serialize_str(self.as_str())
199 }
200}
201
202impl<'de> Deserialize<'de> for UpdateKind {
203 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
204 Ok(UpdateKind::parse(&String::deserialize(d)?))
205 }
206}
207
208impl Work {
209 pub fn retraction(&self) -> Option<&Update> {
211 self.updates.iter().find(|u| u.kind.is_retraction())
212 }
213
214 pub fn type_as_notice(&mut self) {
219 let kinds = || self.update_to.iter().map(|t| &t.kind);
220 let name = if kinds().any(UpdateKind::is_retraction) {
221 "retraction"
222 } else if kinds().any(|k| *k != UpdateKind::NewVersion) {
223 "erratum"
224 } else {
225 return;
226 };
227 self.work_type = Some(WorkType::Other(name.into()));
228 }
229
230 pub fn newer_version(&self) -> Option<&crate::id::WorkId> {
232 self.updates
233 .iter()
234 .filter(|u| u.kind == UpdateKind::NewVersion)
235 .find_map(|u| u.notice.as_ref())
236 }
237}
238
239#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
240#[non_exhaustive]
241pub struct Person {
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub given: Option<String>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub family: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub literal: Option<String>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub orcid: Option<String>,
252}
253
254impl Person {
255 pub fn new(given: impl Into<String>, family: impl Into<String>) -> Self {
256 Person {
257 given: Some(given.into()),
258 family: Some(family.into()),
259 ..Person::default()
260 }
261 }
262
263 pub fn literal(name: impl Into<String>) -> Self {
264 Person {
265 literal: Some(name.into()),
266 ..Person::default()
267 }
268 }
269
270 pub fn parts(&self) -> Option<(String, Option<String>)> {
275 match (&self.family, &self.literal) {
276 (Some(family), _) => Some((family.clone(), self.given.clone())),
277 (None, Some(literal)) => split_literal(literal),
278 (None, None) => None,
279 }
280 }
281
282 pub fn initials(&self) -> Option<String> {
285 let (_, given) = self.parts()?;
286 let initials: String = given?
287 .split(|c: char| c.is_whitespace() || c == '-' || c == '.')
288 .filter_map(|part| part.chars().find(|c| c.is_alphabetic()))
289 .flat_map(char::to_uppercase)
290 .collect();
291 (!initials.is_empty()).then_some(initials)
292 }
293
294 pub fn short(&self) -> Option<String> {
297 match self.parts() {
298 Some((family, _)) => Some(match self.initials() {
299 Some(initials) => format!("{family} {initials}"),
300 None => family,
301 }),
302 None => self.display_name(),
303 }
304 }
305
306 pub fn display_name(&self) -> Option<String> {
308 match (&self.given, &self.family, &self.literal) {
309 (Some(g), Some(f), _) => Some(format!("{g} {f}")),
310 (None, Some(f), _) => Some(f.clone()),
311 (_, _, Some(l)) => Some(l.clone()),
312 (Some(g), None, None) => Some(g.clone()),
313 (None, None, None) => None,
314 }
315 }
316}
317
318const PARTICLES: [&str; 14] = [
320 "van", "von", "de", "der", "den", "da", "di", "du", "la", "le", "del", "dos", "ten", "ter",
321];
322
323const BODIES: [&str; 40] = [
325 "university",
326 "institute",
327 "institution",
328 "group",
329 "team",
330 "organization",
331 "organisation",
332 "consortium",
333 "committee",
334 "association",
335 "society",
336 "collaboration",
337 "network",
338 "centre",
339 "center",
340 "foundation",
341 "agency",
342 "council",
343 "department",
344 "ministry",
345 "office",
346 "board",
347 "federation",
348 "alliance",
349 "project",
350 "initiative",
351 "commission",
352 "programme",
353 "program",
354 "inc",
355 "ltd",
356 "corporation",
357 "company",
358 "trust",
359 "investigators",
360 "study",
361 "panel",
362 "laboratory",
363 "hospital",
364 "college",
365];
366
367fn split_literal(literal: &str) -> Option<(String, Option<String>)> {
371 let words: Vec<&str> = literal.split_whitespace().collect();
372 let personal = (2..=5).contains(&words.len())
373 && words.iter().all(|w| {
374 let lower = w.trim_end_matches(['.', ',']).to_lowercase();
375 let capital = w.chars().next().is_some_and(char::is_uppercase);
376 let acronym = w.chars().count() > 2 && w.chars().all(|c| !c.is_lowercase());
377 (capital || PARTICLES.contains(&lower.as_str()))
378 && !acronym
379 && !BODIES.contains(&lower.as_str())
380 && !w.chars().any(|c| c.is_ascii_digit())
381 });
382 if !personal {
383 return None;
384 }
385 let mut start = words.len() - 1;
386 while start > 1 && PARTICLES.contains(&words[start - 1]) {
387 start -= 1;
388 }
389 Some((words[start..].join(" "), Some(words[..start].join(" "))))
390}
391
392pub fn tidy_abstract(text: &str) -> String {
396 let text = text.trim();
397 for heading in ["Abstract", "ABSTRACT"] {
398 if let Some(rest) = text.strip_prefix(heading)
399 && rest.starts_with(|c: char| c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—'))
400 {
401 return rest
402 .trim_start_matches(|c: char| {
403 c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—')
404 })
405 .to_owned();
406 }
407 }
408 text.to_owned()
409}
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
414pub struct PartialDate {
415 year: i16,
416 month: Option<u8>,
417 day: Option<u8>,
418}
419
420#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
421#[error("invalid date {0:?}: expected YYYY, YYYY-MM or YYYY-MM-DD")]
422pub struct DateError(String);
423
424impl PartialDate {
425 pub fn from_year(year: i16) -> Self {
426 PartialDate {
427 year,
428 month: None,
429 day: None,
430 }
431 }
432
433 pub fn new(year: i16, month: Option<u8>, day: Option<u8>) -> Option<Self> {
435 match (month, day) {
436 (None, Some(_)) => return None,
437 (Some(m), None) if !(1..=12).contains(&m) => return None,
438 (Some(m), Some(d)) => {
439 jiff::civil::Date::new(year, m as i8, d as i8).ok()?;
440 }
441 _ => {}
442 }
443 Some(PartialDate { year, month, day })
444 }
445
446 pub fn year(&self) -> i16 {
447 self.year
448 }
449
450 pub fn month(&self) -> Option<u8> {
451 self.month
452 }
453
454 pub fn day(&self) -> Option<u8> {
455 self.day
456 }
457}
458
459impl FromStr for PartialDate {
460 type Err = DateError;
461
462 fn from_str(s: &str) -> Result<Self, DateError> {
463 let err = || DateError(s.to_owned());
464 let mut parts = s.trim().splitn(3, '-');
465 let mut num = |len: usize| -> Result<Option<u16>, DateError> {
466 match parts.next() {
467 None => Ok(None),
468 Some(p) if p.len() == len && p.bytes().all(|b| b.is_ascii_digit()) => {
469 Ok(Some(p.parse().map_err(|_| err())?))
470 }
471 Some(_) => Err(err()),
472 }
473 };
474 let year = num(4)?.ok_or_else(err)?;
475 let month = num(2)?;
476 let day = num(2)?;
477 PartialDate::new(year as i16, month.map(|m| m as u8), day.map(|d| d as u8)).ok_or_else(err)
478 }
479}
480
481impl fmt::Display for PartialDate {
482 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
483 write!(f, "{:04}", self.year)?;
484 if let Some(m) = self.month {
485 write!(f, "-{m:02}")?;
486 }
487 if let Some(d) = self.day {
488 write!(f, "-{d:02}")?;
489 }
490 Ok(())
491 }
492}
493
494#[derive(Debug, Clone, PartialEq, Eq, Hash)]
497#[non_exhaustive]
498pub enum WorkType {
499 JournalArticle,
500 ConferencePaper,
501 Preprint,
503 Book,
504 Chapter,
505 Thesis,
506 Report,
507 Dataset,
508 Software,
509 Review,
510 Other(String),
511}
512
513impl WorkType {
514 pub fn as_csl(&self) -> &str {
516 match self {
517 WorkType::JournalArticle => "article-journal",
518 WorkType::ConferencePaper => "paper-conference",
519 WorkType::Preprint => "article",
520 WorkType::Book => "book",
521 WorkType::Chapter => "chapter",
522 WorkType::Thesis => "thesis",
523 WorkType::Report => "report",
524 WorkType::Dataset => "dataset",
525 WorkType::Software => "software",
526 WorkType::Review => "review",
527 WorkType::Other(s) => s,
528 }
529 }
530
531 pub fn from_csl(s: &str) -> Self {
533 match s {
534 "article-journal" => WorkType::JournalArticle,
535 "paper-conference" => WorkType::ConferencePaper,
536 "article" => WorkType::Preprint,
537 "book" => WorkType::Book,
538 "chapter" => WorkType::Chapter,
539 "thesis" => WorkType::Thesis,
540 "report" => WorkType::Report,
541 "dataset" => WorkType::Dataset,
542 "software" => WorkType::Software,
543 "review" => WorkType::Review,
544 other => WorkType::Other(other.to_owned()),
545 }
546 }
547}
548
549impl Serialize for WorkType {
550 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
551 s.serialize_str(self.as_csl())
552 }
553}
554
555impl<'de> Deserialize<'de> for WorkType {
556 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
557 Ok(WorkType::from_csl(&String::deserialize(d)?))
558 }
559}
560
561impl Serialize for PartialDate {
562 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
563 s.collect_str(self)
564 }
565}
566
567impl<'de> Deserialize<'de> for PartialDate {
568 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
569 String::deserialize(d)?
570 .parse()
571 .map_err(serde::de::Error::custom)
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn names_split_and_shorten_as_vancouver_writes_them() {
581 let short = |p: Person| p.short().unwrap();
582 assert_eq!(short(Person::new("Dong V.", "Laan")), "Laan DV");
583 assert_eq!(short(Person::new("Thi Diem Ngoc", "Vu")), "Vu TDN");
584 assert_eq!(short(Person::new("Jean-Pierre", "Dupont")), "Dupont JP");
585 assert_eq!(short(Person::literal("Yann LeCun")), "LeCun Y");
586 assert_eq!(short(Person::literal("Vincent van Gogh")), "van Gogh V");
587 assert_eq!(short(Person::literal("WHO")), "WHO");
588 assert_eq!(
589 short(Person::literal("GBD 2019 Collaborators")),
590 "GBD 2019 Collaborators"
591 );
592 assert_eq!(
593 Person::literal("Vincent van Gogh").parts(),
594 Some(("van Gogh".into(), Some("Vincent".into())))
595 );
596 assert_eq!(Person::literal("World Health Organization").parts(), None);
597 }
598
599 #[test]
600 fn partial_dates_parse_validate_and_round_trip() {
601 for s in ["2017", "2017-06", "2017-06-12", "2024-02-29"] {
602 assert_eq!(s.parse::<PartialDate>().unwrap().to_string(), s);
603 }
604 for s in [
605 "17",
606 "2017-6",
607 "2017-13",
608 "2017-06-31",
609 "2023-02-29",
610 "2017--01",
611 "x",
612 ] {
613 assert!(s.parse::<PartialDate>().is_err(), "{s:?}");
614 }
615 assert_eq!(PartialDate::new(2017, None, Some(3)), None);
616 }
617
618 #[test]
619 fn work_type_keeps_unknown_values() {
620 assert_eq!(
621 WorkType::from_csl("article-journal"),
622 WorkType::JournalArticle
623 );
624 let other = WorkType::from_csl("musical-score");
625 assert_eq!(other.as_csl(), "musical-score");
626 }
627
628 #[test]
629 fn work_serialises_sparsely_with_csl_names() {
630 let w = Work {
631 title: Some("Attention Is All You Need".into()),
632 issued: Some(PartialDate::from_year(2017)),
633 work_type: Some(WorkType::ConferencePaper),
634 authors: vec![Person::new("Ashish", "Vaswani")],
635 ..Work::default()
636 };
637 let json = serde_json::to_value(&w).unwrap();
638 assert_eq!(
639 json,
640 serde_json::json!({
641 "title": "Attention Is All You Need",
642 "authors": [{"given": "Ashish", "family": "Vaswani"}],
643 "issued": "2017",
644 "type": "paper-conference",
645 })
646 );
647 assert_eq!(serde_json::from_value::<Work>(json).unwrap(), w);
648 }
649
650 #[test]
651 fn tidy_abstract_drops_only_a_standalone_heading() {
652 assert_eq!(
653 tidy_abstract("Abstract Proteins are essential"),
654 "Proteins are essential"
655 );
656 assert_eq!(tidy_abstract("ABSTRACT: We show"), "We show");
657 assert_eq!(
658 tidy_abstract(" Abstraction is useful"),
659 "Abstraction is useful"
660 );
661 assert_eq!(tidy_abstract("We show"), "We show");
662 }
663
664 #[test]
665 fn person_display_name_prefers_split_names() {
666 assert_eq!(
667 Person::new("Ada", "Lovelace").display_name().unwrap(),
668 "Ada Lovelace"
669 );
670 assert_eq!(Person::literal("CERN").display_name().unwrap(), "CERN");
671 assert_eq!(Person::default().display_name(), None);
672 }
673}