1use nostr_sdk::prelude::*;
10use serde::{Deserialize, Serialize};
11
12use crate::error::ServiceError;
13use crate::user::{serialize_since, truncate_since};
14
15#[derive(Debug, Deserialize, Serialize, Clone)]
20pub struct Rating {
21 pub total_reviews: u64,
23 pub total_rating: f64,
25 pub last_rating: u8,
27 pub max_rate: u8,
29 pub min_rate: u8,
31 #[serde(
47 default,
48 skip_serializing_if = "Option::is_none",
49 serialize_with = "serialize_since"
50 )]
51 pub since: Option<u64>,
52}
53
54impl Rating {
55 pub fn new(
60 total_reviews: u64,
61 total_rating: f64,
62 last_rating: u8,
63 min_rate: u8,
64 max_rate: u8,
65 ) -> Self {
66 Self {
67 total_reviews,
68 total_rating,
69 last_rating,
70 min_rate,
71 max_rate,
72 since: None,
73 }
74 }
75
76 pub fn with_since(mut self, since: u64) -> Self {
82 self.since = Some(truncate_since(since));
83 self
84 }
85
86 pub fn from_json(json: &str) -> Result<Self, ServiceError> {
91 serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
92 }
93
94 pub fn as_json(&self) -> Result<String, ServiceError> {
96 serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
97 }
98
99 pub fn to_tags(&self) -> Tags {
105 let mut tags = vec![
106 Tag::custom("total_reviews", vec![self.total_reviews.to_string()]),
107 Tag::custom("total_rating", vec![self.total_rating.to_string()]),
108 Tag::custom("last_rating", vec![self.last_rating.to_string()]),
109 Tag::custom("max_rate", vec![self.max_rate.to_string()]),
110 Tag::custom("min_rate", vec![self.min_rate.to_string()]),
111 ];
112
113 if let Some(since) = self.since {
116 tags.push(Tag::custom(
117 "since",
118 vec![truncate_since(since).to_string()],
119 ));
120 }
121
122 tags.push(Tag::custom("z", vec!["rating".to_string()]));
123
124 Tags::from_list(tags)
125 }
126
127 pub fn from_tags(tags: Tags) -> Result<Self, ServiceError> {
134 let mut total_reviews = 0;
135 let mut total_rating = 0.0;
136 let mut last_rating = 0;
137 let mut max_rate = 0;
138 let mut min_rate = 0;
139 let mut since = None;
140
141 for tag in tags.into_iter() {
142 let t = tag.to_vec();
143 let key = t
144 .first()
145 .ok_or_else(|| ServiceError::NostrError("Missing tag key".to_string()))?;
146 let value = t
147 .get(1)
148 .ok_or_else(|| ServiceError::NostrError("Missing tag value".to_string()))?;
149 match key.as_str() {
150 "total_reviews" => {
151 total_reviews = value
152 .parse::<u64>()
153 .map_err(|_| ServiceError::ParsingNumberError)?
154 }
155 "total_rating" => {
156 total_rating = value
157 .parse::<f64>()
158 .map_err(|_| ServiceError::ParsingNumberError)?
159 }
160 "last_rating" => {
161 last_rating = value
162 .parse::<u8>()
163 .map_err(|_| ServiceError::ParsingNumberError)?
164 }
165 "max_rate" => {
166 max_rate = value
167 .parse::<u8>()
168 .map_err(|_| ServiceError::ParsingNumberError)?
169 }
170 "min_rate" => {
171 min_rate = value
172 .parse::<u8>()
173 .map_err(|_| ServiceError::ParsingNumberError)?
174 }
175 "since" => {
176 since = Some(
177 value
178 .parse::<u64>()
179 .map_err(|_| ServiceError::ParsingNumberError)?,
180 )
181 }
182 _ => {}
183 }
184 }
185
186 Ok(Self {
187 total_reviews,
188 total_rating,
189 last_rating,
190 max_rate,
191 min_rate,
192 since,
193 })
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 const SINCE: u64 = 1_767_225_600;
203
204 fn sample() -> Rating {
205 Rating::new(7, 4.5, 5, 1, 5)
206 }
207
208 fn tag_value(tags: &Tags, key: &str) -> Option<String> {
209 tags.iter().find_map(|tag| {
210 let t = tag.clone().to_vec();
211 (t.first().map(String::as_str) == Some(key)).then(|| t.get(1).cloned())?
212 })
213 }
214
215 #[test]
216 fn new_leaves_since_unset_and_json_omits_it() {
217 let rating = sample();
219
220 let json = rating.as_json().expect("serializes");
222
223 assert_eq!(rating.since, None);
227 assert!(!json.contains("since"), "{json}");
228 }
229
230 #[test]
231 fn with_since_sets_it_and_json_carries_it() {
232 let rating = sample().with_since(SINCE);
234 let json = rating.as_json().expect("serializes");
235
236 assert_eq!(rating.since, Some(SINCE));
238 assert!(json.contains(&format!("\"since\":{SINCE}")), "{json}");
239 }
240
241 #[test]
242 fn json_without_since_still_parses() {
243 let json =
245 r#"{"total_reviews":7,"total_rating":4.5,"last_rating":5,"max_rate":5,"min_rate":1}"#;
246
247 let rating = Rating::from_json(json).expect("parses");
249
250 assert_eq!(rating.since, None);
252 assert_eq!(rating.total_reviews, 7);
253 }
254
255 #[test]
256 fn json_round_trips_with_since() {
257 let original = sample().with_since(SINCE);
259
260 let parsed = Rating::from_json(&original.as_json().expect("serializes")).expect("parses");
262
263 assert_eq!(parsed.since, Some(SINCE));
265 assert_eq!(parsed.total_rating, original.total_rating);
266 }
267
268 #[test]
269 fn tags_omit_since_when_unset() {
270 let tags = sample().to_tags();
272
273 assert_eq!(tag_value(&tags, "since"), None);
276 assert_eq!(tag_value(&tags, "z"), Some("rating".to_string()));
277 }
278
279 #[test]
280 fn tags_carry_since_when_set() {
281 let tags = sample().with_since(SINCE).to_tags();
283
284 assert_eq!(tag_value(&tags, "since"), Some(SINCE.to_string()));
286 assert_eq!(tag_value(&tags, "z"), Some("rating".to_string()));
288 }
289
290 #[test]
291 fn tags_round_trip_with_since() {
292 let original = sample().with_since(SINCE);
294
295 let parsed = Rating::from_tags(original.to_tags()).expect("parses");
297
298 assert_eq!(parsed.since, Some(SINCE));
300 assert_eq!(parsed.total_reviews, original.total_reviews);
301 assert_eq!(parsed.min_rate, original.min_rate);
302 }
303
304 #[test]
305 fn tags_without_since_parse_as_none() {
306 let tags = sample().to_tags();
308
309 let parsed = Rating::from_tags(tags).expect("parses");
311
312 assert_eq!(parsed.since, None);
314 }
315
316 #[test]
317 fn with_since_truncates_to_the_start_of_the_utc_day() {
318 let rating = sample().with_since(SINCE + 86_399);
321
322 assert_eq!(rating.since, Some(SINCE));
324 }
325
326 #[test]
327 fn a_since_set_directly_is_truncated_on_the_way_out() {
328 let mut rating = sample();
330 rating.since = Some(SINCE + 3_600);
331
332 let json = rating.as_json().expect("serializes");
334 let tags = rating.to_tags();
335
336 assert!(json.contains(&format!("\"since\":{SINCE}")), "{json}");
338 assert_eq!(tag_value(&tags, "since"), Some(SINCE.to_string()));
339 }
340
341 #[test]
342 fn an_unparseable_since_is_an_error_not_a_silent_zero() {
343 let tags = Tags::from_list(vec![
346 Tag::custom("total_reviews", vec!["7".to_string()]),
347 Tag::custom("since", vec!["yesterday".to_string()]),
348 ]);
349
350 let parsed = Rating::from_tags(tags);
352
353 assert!(parsed.is_err());
355 }
356}