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