Skip to main content

aspero_json/
claim.rs

1//! Claims as callers write them: one JSON object (or plain reference) per
2//! line, echoed back on every result.
3
4use 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/// A claim as an agent would write it, one JSON object per stdin line.
11/// Unknown keys are rejected so typos aren't silently ignored.
12#[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    /// The first page, or a range.
25    #[serde(default, alias = "pages", deserialize_with = "text_or_number")]
26    pub page: Option<String>,
27    /// The caller's own reference for this claim, echoed back untouched;
28    /// allowed here, read from the raw line (see [`InputClaim::from_line`]).
29    #[serde(rename = "key")]
30    pub _key: Option<serde::de::IgnoredAny>,
31    pub doi: Option<String>,
32    pub arxiv: Option<String>,
33    /// Any identifier form `lookup` accepts.
34    pub id: Option<String>,
35    #[serde(default)]
36    pub ids: Vec<String>,
37}
38
39/// Volumes and pages are written as numbers or strings ("e1234").
40fn 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    /// The fields as one line, to label a claim given without text.
55    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
104/// A claim with the input it came from and the caller's `key`, or why that
105/// input isn't one.
106pub struct InputClaim {
107    pub input: String,
108    pub key: Option<Value>,
109    pub claim: Result<Claim, String>,
110}
111
112impl InputClaim {
113    /// One stdin line: a JSON object or a plain reference. A JSON line's
114    /// `key` is kept even when the rest of it is invalid.
115    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    /// What every result for this claim starts with: the input, the
141    /// caller's key, and the claim as understood.
142    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}