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
8/// A claim as an agent would write it, one JSON object per stdin line.
9/// Unknown keys are rejected so typos aren't silently ignored.
10#[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    /// The first page, or a range.
23    #[serde(default, alias = "pages", deserialize_with = "text_or_number")]
24    pub page: Option<String>,
25    /// The caller's own reference for this claim, echoed back untouched;
26    /// allowed here, read from the raw line (see [`InputClaim::from_line`]).
27    #[serde(rename = "key")]
28    pub _key: Option<serde::de::IgnoredAny>,
29    pub doi: Option<String>,
30    pub arxiv: Option<String>,
31    /// Any identifier form `lookup` accepts.
32    pub id: Option<String>,
33    #[serde(default)]
34    pub ids: Vec<String>,
35}
36
37/// Volumes and pages are written as numbers or strings ("e1234").
38fn 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
81/// A claim with the input it came from and the caller's `key`, or why that
82/// input isn't one.
83pub struct InputClaim {
84    pub input: String,
85    pub key: Option<Value>,
86    pub claim: Result<Claim, String>,
87}
88
89impl InputClaim {
90    /// One stdin line: a JSON object or a plain reference. A JSON line's
91    /// `key` is kept even when the rest of it is invalid.
92    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    /// What every result for this claim starts with: the input, the
113    /// caller's key, and the claim as understood.
114    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}