1use aspero_core::{Error, ErrorCategory, ErrorKind};
4use aspero_engine::{Disagreement, InputLookup, LookupResult, Outcome, Served};
5use serde_json::{Value, json};
6
7use crate::work;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum Status {
13 Found,
14 Unsupported,
16 NotFound,
17 NotCached,
19 Failed(ErrorCategory),
21}
22
23impl Status {
24 pub fn of(result: &LookupResult) -> Self {
25 if result.record.is_some() {
26 return Status::Found;
27 }
28 if result.attempts.is_empty() {
29 return Status::Unsupported;
30 }
31 let failures: Vec<&Error> = result
32 .attempts
33 .iter()
34 .filter_map(|a| match &a.outcome {
35 Outcome::Failed(e) => Some(e),
36 _ => None,
37 })
38 .collect();
39 match failures.first() {
40 _ if failures.len() < result.attempts.len() => Status::NotFound,
41 Some(_) if failures.iter().all(|e| e.kind() == ErrorKind::NotCached) => {
42 Status::NotCached
43 }
44 Some(first) => Status::Failed(first.category()),
45 None => Status::NotFound,
46 }
47 }
48
49 pub fn as_str(self) -> &'static str {
50 match self {
51 Status::Found => "found",
52 Status::Unsupported => "unsupported",
53 Status::NotFound => "not_found",
54 Status::NotCached => "not_cached",
55 Status::Failed(_) => "failed",
56 }
57 }
58}
59
60pub fn invalid(input: &str, error: &str) -> Value {
62 json!({"input": input, "status": "invalid", "error": error})
63}
64
65pub fn item(input: &str, result: &LookupResult, full: bool) -> Value {
68 let status = Status::of(result);
69 let mut item = json!({"input": input, "id": result.id, "status": status.as_str()});
70 if let Some(record) = &result.record {
71 let served = result
73 .attempts
74 .iter()
75 .rfind(|a| matches!(a.outcome, Outcome::Found))
76 .map(|a| a.served);
77 if let Some(s) = served.filter(|s| *s != Served::Live) {
78 item["served"] = json!(s.as_str());
79 }
80 item["work"] = work::work(record, full);
81 }
82 if status == Status::Unsupported {
83 item["error"] = json!(format!(
84 "no enabled provider looks up {} identifiers",
85 result.id.kind()
86 ));
87 }
88 if result.record.is_none() || full {
89 let attempts: Vec<Value> = result
90 .attempts
91 .iter()
92 .map(|a| match &a.outcome {
93 Outcome::Found => json!({"provider": a.provider, "outcome": "found"}),
94 Outcome::NotFound => json!({"provider": a.provider, "outcome": "not_found"}),
95 Outcome::Failed(e) => {
96 json!({
97 "provider": a.provider,
98 "outcome": "failed",
99 "kind": e.kind().as_str(),
100 "error": e.to_string(),
101 })
102 }
103 _ => json!({"provider": a.provider, "outcome": "unknown"}),
104 })
105 .collect();
106 item["attempts"] = json!(attempts);
107 }
108 item
109}
110
111pub fn input(input: &str, lookup: &InputLookup, full: bool) -> Value {
113 let looked = match lookup {
114 InputLookup::Invalid(e) => return invalid(input, &e.to_string()),
115 InputLookup::Looked(looked) => looked,
116 };
117 let mut out = item(input, &looked.result, full);
118 if looked.with_punctuation {
119 out["note"] = json!("found with the trailing punctuation kept");
120 }
121 if let Some(found) = &looked.abstract_text {
122 work::set_abstract(&mut out["work"], found.as_ref());
123 }
124 if let Some(latest) = &looked.latest {
125 out["latest"] = work::work(latest, false);
126 }
127 if !looked.disagreements.is_empty() {
128 out["disagreements"] = disagreements(&looked.disagreements);
129 }
130 out
131}
132
133pub fn disagreements(found: &[Disagreement]) -> Value {
136 found
137 .iter()
138 .map(|d| {
139 json!({
140 "field": d.field,
141 "value": d.value,
142 d.registry.as_str(): d.registry_value,
143 })
144 })
145 .collect()
146}
147
148#[cfg(test)]
149mod tests {
150 use aspero_core::ProviderId;
151 use aspero_engine::Attempt;
152
153 use super::*;
154
155 fn result(outcomes: Vec<Outcome>) -> LookupResult {
156 LookupResult {
157 id: "doi:10.1234/x".parse().unwrap(),
158 record: None,
159 attempts: outcomes
160 .into_iter()
161 .map(|outcome| Attempt {
162 provider: ProviderId::from_static("crossref"),
163 outcome,
164 served: Served::Live,
165 })
166 .collect(),
167 }
168 }
169
170 #[test]
171 fn statuses_say_why_nothing_was_found() {
172 assert_eq!(Status::of(&result(vec![])), Status::Unsupported);
173 assert_eq!(
174 Status::of(&result(vec![Outcome::NotFound])),
175 Status::NotFound
176 );
177 let failed = || Outcome::Failed(Error::Timeout);
178 assert_eq!(
179 Status::of(&result(vec![failed(), Outcome::NotFound])),
180 Status::NotFound
181 );
182 assert!(matches!(
183 Status::of(&result(vec![failed(), failed()])),
184 Status::Failed(_)
185 ));
186 let offline = || Outcome::Failed(Error::Offline);
187 assert_eq!(
188 Status::of(&result(vec![offline(), offline()])),
189 Status::NotCached
190 );
191 }
192
193 #[test]
194 fn items_without_a_record_carry_their_attempts() {
195 assert_eq!(
196 item("10.1234/x", &result(vec![Outcome::NotFound]), false),
197 json!({
198 "input": "10.1234/x",
199 "id": "doi:10.1234/x",
200 "status": "not_found",
201 "attempts": [{"provider": "crossref", "outcome": "not_found"}],
202 })
203 );
204 let unsupported = item("10.1234/x", &result(vec![]), false);
205 assert_eq!(unsupported["status"], "unsupported");
206 assert!(unsupported["error"].as_str().unwrap().contains("doi"));
207 }
208}