Skip to main content

aspero_core/
record.rs

1//! What a provider returns about one work: normalised fields, every identifier
2//! it asserts, and the untouched raw response.
3
4use std::fmt;
5
6use jiff::Timestamp;
7use serde::{Deserialize, Serialize};
8
9use crate::id::WorkId;
10use crate::provider::ProviderId;
11use crate::work::Work;
12
13/// One provider's view of one work. Providers never merge records; the
14/// engine does, keeping each `SourceRecord` as the recomputable source of
15/// truth.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[non_exhaustive]
18pub struct SourceRecord {
19    pub source: ProviderId,
20    /// Every identifier the provider asserts for this work, its own native
21    /// ID first. These are the edges of the identifier graph.
22    pub ids: Vec<WorkId>,
23    pub work: Work,
24    /// The fields this record is authoritative for. A `None` inside a covered
25    /// field means "the provider has no value", not "not asked". The cache
26    /// serves a request from this record iff the request's fields are a subset.
27    pub fields: FieldSet,
28    pub raw: Raw,
29    pub fetched_at: Timestamp,
30}
31
32impl SourceRecord {
33    /// `fetched_at` is passed in rather than read here: `core` doesn't touch
34    /// the clock, which keeps it deterministic in tests and portable to WASM.
35    pub fn new(
36        source: ProviderId,
37        ids: Vec<WorkId>,
38        work: Work,
39        fields: FieldSet,
40        raw: Raw,
41        fetched_at: Timestamp,
42    ) -> Self {
43        SourceRecord {
44            source,
45            ids,
46            work,
47            fields,
48            raw,
49            fetched_at,
50        }
51    }
52
53    /// The provider's own identifier for the work, if it gave any.
54    pub fn native_id(&self) -> Option<&WorkId> {
55        self.ids.first()
56    }
57}
58
59/// The provider's response body for this work, verbatim, so normalisation
60/// can be improved and rerun without refetching.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct Raw {
63    /// e.g. `application/json`, `application/x-bibtex`.
64    pub media_type: String,
65    pub body: String,
66}
67
68impl Raw {
69    pub fn json(value: &serde_json::Value) -> Self {
70        Raw {
71            media_type: "application/json".into(),
72            body: value.to_string(),
73        }
74    }
75}
76
77/// A selectable group of [`Work`] fields. Identifiers are always included.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80#[non_exhaustive]
81pub enum Field {
82    Title,
83    Authors,
84    Issued,
85    Type,
86    /// Container title.
87    Container,
88    /// Volume, issue, page and publisher.
89    Publication,
90    Abstract,
91    Url,
92    CitedByCount,
93    /// Retractions, corrections and other notices.
94    Updates,
95    /// Where a free, legal copy is.
96    OpenAccess,
97}
98
99impl Field {
100    /// Every field. A slice, so adding a field is not a breaking change.
101    pub const ALL: &[Field] = &[
102        Field::Title,
103        Field::Authors,
104        Field::Issued,
105        Field::Type,
106        Field::Container,
107        Field::Publication,
108        Field::Abstract,
109        Field::Url,
110        Field::CitedByCount,
111        Field::Updates,
112        Field::OpenAccess,
113    ];
114
115    const fn bit(self) -> u16 {
116        1 << self as u16
117    }
118}
119
120/// A set of [`Field`]s. Serialises as a list of field names.
121#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
122pub struct FieldSet(u16);
123
124impl FieldSet {
125    pub const EMPTY: FieldSet = FieldSet(0);
126    pub const ALL: FieldSet = FieldSet::of(Field::ALL);
127    /// The lean default: enough to identify and cite a work.
128    pub const DEFAULT: FieldSet = FieldSet::of(&[
129        Field::Title,
130        Field::Authors,
131        Field::Issued,
132        Field::Type,
133        Field::Container,
134    ]);
135
136    pub const fn of(fields: &[Field]) -> FieldSet {
137        let mut bits = 0;
138        let mut i = 0;
139        while i < fields.len() {
140            bits |= fields[i].bit();
141            i += 1;
142        }
143        FieldSet(bits)
144    }
145
146    pub const fn contains(self, field: Field) -> bool {
147        self.0 & field.bit() != 0
148    }
149
150    pub const fn is_subset(self, other: FieldSet) -> bool {
151        self.0 & !other.0 == 0
152    }
153
154    pub const fn union(self, other: FieldSet) -> FieldSet {
155        FieldSet(self.0 | other.0)
156    }
157
158    pub const fn difference(self, other: FieldSet) -> FieldSet {
159        FieldSet(self.0 & !other.0)
160    }
161
162    pub const fn intersection(self, other: FieldSet) -> FieldSet {
163        FieldSet(self.0 & other.0)
164    }
165
166    pub const fn with(self, field: Field) -> FieldSet {
167        FieldSet(self.0 | field.bit())
168    }
169
170    pub const fn is_empty(self) -> bool {
171        self.0 == 0
172    }
173
174    pub fn iter(self) -> impl Iterator<Item = Field> {
175        Field::ALL
176            .iter()
177            .copied()
178            .filter(move |f| self.contains(*f))
179    }
180}
181
182impl FromIterator<Field> for FieldSet {
183    fn from_iter<I: IntoIterator<Item = Field>>(iter: I) -> Self {
184        iter.into_iter().fold(FieldSet::EMPTY, FieldSet::with)
185    }
186}
187
188impl fmt::Debug for FieldSet {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        f.debug_set().entries(self.iter()).finish()
191    }
192}
193
194impl Serialize for FieldSet {
195    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
196        s.collect_seq(self.iter())
197    }
198}
199
200impl<'de> Deserialize<'de> for FieldSet {
201    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
202        Ok(Vec::<Field>::deserialize(d)?.into_iter().collect())
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn subset_logic_drives_cache_hits() {
212        let cached = FieldSet::DEFAULT;
213        assert!(FieldSet::of(&[Field::Title, Field::Issued]).is_subset(cached));
214        assert!(!FieldSet::DEFAULT.with(Field::Abstract).is_subset(cached));
215        assert_eq!(
216            FieldSet::DEFAULT.with(Field::Abstract).difference(cached),
217            FieldSet::of(&[Field::Abstract])
218        );
219        assert!(FieldSet::EMPTY.is_subset(FieldSet::EMPTY));
220        assert!(FieldSet::ALL.iter().eq(Field::ALL.iter().copied()));
221    }
222
223    #[test]
224    fn field_set_serialises_as_names() {
225        let set = FieldSet::of(&[Field::Title, Field::CitedByCount]);
226        let json = serde_json::to_string(&set).unwrap();
227        assert_eq!(json, r#"["title","cited_by_count"]"#);
228        assert_eq!(serde_json::from_str::<FieldSet>(&json).unwrap(), set);
229    }
230}