1use aspero_core::{Claim, WorkId};
5use serde::Deserialize;
6use serde_json::{Value, json};
7
8#[derive(Debug, Default, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct ClaimInput {
13 pub text: Option<String>,
14 pub title: Option<String>,
15 #[serde(default)]
16 pub authors: Vec<String>,
17 pub year: Option<i16>,
18 #[serde(alias = "journal")]
19 pub container: Option<String>,
20 #[serde(default, deserialize_with = "text_or_number")]
21 pub volume: Option<String>,
22 #[serde(default, alias = "pages", deserialize_with = "text_or_number")]
24 pub page: Option<String>,
25 #[serde(rename = "key")]
28 pub _key: Option<serde::de::IgnoredAny>,
29 pub doi: Option<String>,
30 pub arxiv: Option<String>,
31 pub id: Option<String>,
33 #[serde(default)]
34 pub ids: Vec<String>,
35}
36
37fn text_or_number<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
39 Ok(match Option::<serde_json::Value>::deserialize(d)? {
40 Some(serde_json::Value::String(s)) => Some(s),
41 Some(serde_json::Value::Number(n)) => Some(n.to_string()),
42 None | Some(serde_json::Value::Null) => None,
43 Some(other) => {
44 return Err(serde::de::Error::custom(format!(
45 "expected text or a number, got {other}"
46 )));
47 }
48 })
49}
50
51impl ClaimInput {
52 pub fn into_claim(self) -> Result<Claim, String> {
53 let mut claim = match &self.text {
54 Some(text) => Claim::from_input(text),
55 None => Claim::default(),
56 };
57 let prefixed = [
58 self.doi.map(|d| format!("doi:{d}")),
59 self.arxiv.map(|a| format!("arxiv:{a}")),
60 self.id,
61 ];
62 for raw in prefixed.into_iter().flatten().chain(self.ids) {
63 let id: WorkId = raw.parse().map_err(|e| format!("{e}"))?;
64 if !claim.ids.contains(&id) {
65 claim.ids.push(id);
66 }
67 }
68 claim.title = self.title;
69 claim.authors = self.authors;
70 claim.year = self.year;
71 claim.container = self.container;
72 claim.volume = self.volume;
73 claim.page = self.page;
74 if claim.is_empty() {
75 return Err("claim has no text, title, authors or identifiers".into());
76 }
77 Ok(claim)
78 }
79}
80
81pub struct InputClaim {
84 pub input: String,
85 pub key: Option<Value>,
86 pub claim: Result<Claim, String>,
87}
88
89impl InputClaim {
90 pub fn from_line(line: &str) -> Self {
93 let (key, claim) = match line.trim_start().starts_with('{') {
94 false => (None, Ok(Claim::from_input(line))),
95 true => match serde_json::from_str::<Value>(line) {
96 Err(e) => (None, Err(format!("invalid claim JSON: {e}"))),
97 Ok(value) => (
98 value.get("key").cloned(),
99 ClaimInput::deserialize(value)
100 .map_err(|e| format!("invalid claim: {e}"))
101 .and_then(ClaimInput::into_claim),
102 ),
103 },
104 };
105 InputClaim {
106 input: line.to_owned(),
107 key,
108 claim,
109 }
110 }
111
112 pub fn echo(&self) -> Value {
115 let mut echo = json!({ "input": self.input });
116 if let Some(key) = &self.key {
117 echo["key"] = key.clone();
118 }
119 if let Ok(claim) = &self.claim {
120 echo["claim"] = json!(claim);
121 }
122 echo
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 fn claim(line: &str) -> Result<Claim, String> {
131 InputClaim::from_line(line).claim
132 }
133
134 #[test]
135 fn json_lines_become_structured_claims() {
136 let c = claim(
137 r#"{"title": "Attention is all you need", "authors": ["Vaswani, A."], "year": 2017, "arxiv": "1706.03762"}"#,
138 )
139 .unwrap();
140 assert_eq!(c.year, Some(2017));
141 assert_eq!(c.ids[0].to_string(), "arxiv:1706.03762");
142 assert!(
143 claim(r#"{"titel": "typo"}"#)
144 .unwrap_err()
145 .contains("unknown field")
146 );
147 assert!(claim(r#"{"doi": "not a doi"}"#).is_err());
148 }
149
150 #[test]
151 fn journal_volume_and_page_as_text_or_numbers() {
152 let c = claim(
153 r#"{"title": "X", "journal": "N Engl J Med", "volume": 376, "pages": "2545-55"}"#,
154 )
155 .unwrap();
156 assert_eq!(c.container.as_deref(), Some("N Engl J Med"));
157 assert_eq!(c.volume.as_deref(), Some("376"));
158 assert_eq!(c.page.as_deref(), Some("2545-55"));
159 assert!(claim(r#"{"title": "X", "volume": [1]}"#).is_err());
160 }
161
162 #[test]
163 fn keys_are_echoed_even_for_invalid_claims() {
164 let ok = InputClaim::from_line(r#"{"key": {"note": "pe.md", "line": 7}, "title": "X"}"#);
165 assert_eq!(ok.echo()["key"], json!({"note": "pe.md", "line": 7}));
166 assert_eq!(ok.echo()["claim"], json!({"title": "X"}));
167 let bad = InputClaim::from_line(r#"{"key": "ref-12", "titel": "typo"}"#);
168 assert!(bad.claim.is_err());
169 assert_eq!(bad.echo()["key"], "ref-12");
170 assert!(bad.echo().get("claim").is_none());
171 }
172}