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 display_name(&self) -> Option<String> {
272 match (&self.given, &self.family, &self.literal) {
273 (Some(g), Some(f), _) => Some(format!("{g} {f}")),
274 (None, Some(f), _) => Some(f.clone()),
275 (_, _, Some(l)) => Some(l.clone()),
276 (Some(g), None, None) => Some(g.clone()),
277 (None, None, None) => None,
278 }
279 }
280}
281
282pub fn tidy_abstract(text: &str) -> String {
286 let text = text.trim();
287 for heading in ["Abstract", "ABSTRACT"] {
288 if let Some(rest) = text.strip_prefix(heading)
289 && rest.starts_with(|c: char| c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—'))
290 {
291 return rest
292 .trim_start_matches(|c: char| {
293 c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—')
294 })
295 .to_owned();
296 }
297 }
298 text.to_owned()
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
304pub struct PartialDate {
305 year: i16,
306 month: Option<u8>,
307 day: Option<u8>,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
311#[error("invalid date {0:?}: expected YYYY, YYYY-MM or YYYY-MM-DD")]
312pub struct DateError(String);
313
314impl PartialDate {
315 pub fn from_year(year: i16) -> Self {
316 PartialDate {
317 year,
318 month: None,
319 day: None,
320 }
321 }
322
323 pub fn new(year: i16, month: Option<u8>, day: Option<u8>) -> Option<Self> {
325 match (month, day) {
326 (None, Some(_)) => return None,
327 (Some(m), None) if !(1..=12).contains(&m) => return None,
328 (Some(m), Some(d)) => {
329 jiff::civil::Date::new(year, m as i8, d as i8).ok()?;
330 }
331 _ => {}
332 }
333 Some(PartialDate { year, month, day })
334 }
335
336 pub fn year(&self) -> i16 {
337 self.year
338 }
339
340 pub fn month(&self) -> Option<u8> {
341 self.month
342 }
343
344 pub fn day(&self) -> Option<u8> {
345 self.day
346 }
347}
348
349impl FromStr for PartialDate {
350 type Err = DateError;
351
352 fn from_str(s: &str) -> Result<Self, DateError> {
353 let err = || DateError(s.to_owned());
354 let mut parts = s.trim().splitn(3, '-');
355 let mut num = |len: usize| -> Result<Option<u16>, DateError> {
356 match parts.next() {
357 None => Ok(None),
358 Some(p) if p.len() == len && p.bytes().all(|b| b.is_ascii_digit()) => {
359 Ok(Some(p.parse().map_err(|_| err())?))
360 }
361 Some(_) => Err(err()),
362 }
363 };
364 let year = num(4)?.ok_or_else(err)?;
365 let month = num(2)?;
366 let day = num(2)?;
367 PartialDate::new(year as i16, month.map(|m| m as u8), day.map(|d| d as u8)).ok_or_else(err)
368 }
369}
370
371impl fmt::Display for PartialDate {
372 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
373 write!(f, "{:04}", self.year)?;
374 if let Some(m) = self.month {
375 write!(f, "-{m:02}")?;
376 }
377 if let Some(d) = self.day {
378 write!(f, "-{d:02}")?;
379 }
380 Ok(())
381 }
382}
383
384#[derive(Debug, Clone, PartialEq, Eq, Hash)]
387#[non_exhaustive]
388pub enum WorkType {
389 JournalArticle,
390 ConferencePaper,
391 Preprint,
393 Book,
394 Chapter,
395 Thesis,
396 Report,
397 Dataset,
398 Software,
399 Review,
400 Other(String),
401}
402
403impl WorkType {
404 pub fn as_csl(&self) -> &str {
406 match self {
407 WorkType::JournalArticle => "article-journal",
408 WorkType::ConferencePaper => "paper-conference",
409 WorkType::Preprint => "article",
410 WorkType::Book => "book",
411 WorkType::Chapter => "chapter",
412 WorkType::Thesis => "thesis",
413 WorkType::Report => "report",
414 WorkType::Dataset => "dataset",
415 WorkType::Software => "software",
416 WorkType::Review => "review",
417 WorkType::Other(s) => s,
418 }
419 }
420
421 pub fn from_csl(s: &str) -> Self {
423 match s {
424 "article-journal" => WorkType::JournalArticle,
425 "paper-conference" => WorkType::ConferencePaper,
426 "article" => WorkType::Preprint,
427 "book" => WorkType::Book,
428 "chapter" => WorkType::Chapter,
429 "thesis" => WorkType::Thesis,
430 "report" => WorkType::Report,
431 "dataset" => WorkType::Dataset,
432 "software" => WorkType::Software,
433 "review" => WorkType::Review,
434 other => WorkType::Other(other.to_owned()),
435 }
436 }
437}
438
439impl Serialize for WorkType {
440 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
441 s.serialize_str(self.as_csl())
442 }
443}
444
445impl<'de> Deserialize<'de> for WorkType {
446 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
447 Ok(WorkType::from_csl(&String::deserialize(d)?))
448 }
449}
450
451impl Serialize for PartialDate {
452 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
453 s.collect_str(self)
454 }
455}
456
457impl<'de> Deserialize<'de> for PartialDate {
458 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
459 String::deserialize(d)?
460 .parse()
461 .map_err(serde::de::Error::custom)
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn partial_dates_parse_validate_and_round_trip() {
471 for s in ["2017", "2017-06", "2017-06-12", "2024-02-29"] {
472 assert_eq!(s.parse::<PartialDate>().unwrap().to_string(), s);
473 }
474 for s in [
475 "17",
476 "2017-6",
477 "2017-13",
478 "2017-06-31",
479 "2023-02-29",
480 "2017--01",
481 "x",
482 ] {
483 assert!(s.parse::<PartialDate>().is_err(), "{s:?}");
484 }
485 assert_eq!(PartialDate::new(2017, None, Some(3)), None);
486 }
487
488 #[test]
489 fn work_type_keeps_unknown_values() {
490 assert_eq!(
491 WorkType::from_csl("article-journal"),
492 WorkType::JournalArticle
493 );
494 let other = WorkType::from_csl("musical-score");
495 assert_eq!(other.as_csl(), "musical-score");
496 }
497
498 #[test]
499 fn work_serialises_sparsely_with_csl_names() {
500 let w = Work {
501 title: Some("Attention Is All You Need".into()),
502 issued: Some(PartialDate::from_year(2017)),
503 work_type: Some(WorkType::ConferencePaper),
504 authors: vec![Person::new("Ashish", "Vaswani")],
505 ..Work::default()
506 };
507 let json = serde_json::to_value(&w).unwrap();
508 assert_eq!(
509 json,
510 serde_json::json!({
511 "title": "Attention Is All You Need",
512 "authors": [{"given": "Ashish", "family": "Vaswani"}],
513 "issued": "2017",
514 "type": "paper-conference",
515 })
516 );
517 assert_eq!(serde_json::from_value::<Work>(json).unwrap(), w);
518 }
519
520 #[test]
521 fn tidy_abstract_drops_only_a_standalone_heading() {
522 assert_eq!(
523 tidy_abstract("Abstract Proteins are essential"),
524 "Proteins are essential"
525 );
526 assert_eq!(tidy_abstract("ABSTRACT: We show"), "We show");
527 assert_eq!(
528 tidy_abstract(" Abstraction is useful"),
529 "Abstraction is useful"
530 );
531 assert_eq!(tidy_abstract("We show"), "We show");
532 }
533
534 #[test]
535 fn person_display_name_prefers_split_names() {
536 assert_eq!(
537 Person::new("Ada", "Lovelace").display_name().unwrap(),
538 "Ada Lovelace"
539 );
540 assert_eq!(Person::literal("CERN").display_name().unwrap(), "CERN");
541 assert_eq!(Person::default().display_name(), None);
542 }
543}