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 = "Vec::is_empty")]
58 pub other_years: Vec<i16>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[non_exhaustive]
65pub struct Update {
66 pub kind: UpdateKind,
67 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[non_exhaustive]
92pub struct UpdateTo {
93 pub kind: UpdateKind,
94 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 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 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 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 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub literal: Option<String>,
181 #[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 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
214pub 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#[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 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
319#[non_exhaustive]
320pub enum WorkType {
321 JournalArticle,
322 ConferencePaper,
323 Preprint,
325 Book,
326 Chapter,
327 Thesis,
328 Report,
329 Dataset,
330 Software,
331 Review,
332 Other(String),
333}
334
335impl WorkType {
336 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 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}