Skip to main content

mostro_core/
rating.rs

1//! Encoding of user reputation as Nostr event tags.
2//!
3//! Mostro publishes user reputation as addressable Nostr events of kind
4//! [`NOSTR_RATING_EVENT_KIND`](crate::prelude::NOSTR_RATING_EVENT_KIND). The
5//! [`Rating`] struct in this module mirrors the tag set used on those events
6//! and provides helpers to serialize to / deserialize from both JSON and
7//! `nostr_sdk::Tags`.
8
9use nostr_sdk::prelude::*;
10use serde::{Deserialize, Serialize};
11
12use crate::error::ServiceError;
13use crate::user::{serialize_since, truncate_since};
14
15/// User reputation snapshot, suitable for publishing as Nostr tags.
16///
17/// The fields are the same aggregates stored on [`crate::user::User`], but
18/// typed for transport (unsigned integers for counts, `u8` for rating values).
19#[derive(Debug, Deserialize, Serialize, Clone)]
20pub struct Rating {
21    /// Total number of ratings received.
22    pub total_reviews: u64,
23    /// Weighted rating average across all reviews.
24    pub total_rating: f64,
25    /// Most recent rating, in the `MIN_RATING..=MAX_RATING` range.
26    pub last_rating: u8,
27    /// Highest rating ever received.
28    pub max_rate: u8,
29    /// Lowest rating ever received.
30    pub min_rate: u8,
31    /// Unix timestamp of the user's first trade, truncated to the start of its
32    /// UTC day, or `None` from a peer that does not publish it yet.
33    ///
34    /// Supersedes the `days` count the daemon publishes alongside it. A day
35    /// count is derived when the event is written, so it is stale on any event
36    /// that sits on relays for a while, and it cannot be merged when
37    /// reputation is imported from another venue; a date can be.
38    ///
39    /// Day precision is deliberate rather than incidental. This value travels
40    /// on every order of the same user, so a second-precision timestamp would
41    /// be a fingerprint linking their trade pubkeys together.
42    ///
43    /// Skipped when absent, so a `Rating` that never sets it serializes
44    /// exactly as it did before this field existed. Always published truncated
45    /// to its UTC day, in JSON and in tags, however it was set.
46    #[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    /// Construct a new [`Rating`] from its individual components.
56    ///
57    /// Leaves [`Rating::since`] unset; add it with [`Rating::with_since`].
58    /// The signature is deliberately unchanged so no caller has to move.
59    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    /// Attach the date of the user's first trade.
77    ///
78    /// `since` is truncated to the start of its UTC day with the same rule as
79    /// [`day_truncate`](crate::user::day_truncate), so a caller that passes a
80    /// raw timestamp cannot leak second precision.
81    pub fn with_since(mut self, since: u64) -> Self {
82        self.since = Some(truncate_since(since));
83        self
84    }
85
86    /// Parse a [`Rating`] from its JSON representation.
87    ///
88    /// Returns [`ServiceError::MessageSerializationError`] if `json` is not a
89    /// valid serialization of this type.
90    pub fn from_json(json: &str) -> Result<Self, ServiceError> {
91        serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
92    }
93
94    /// Serialize the rating to a JSON string.
95    pub fn as_json(&self) -> Result<String, ServiceError> {
96        serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
97    }
98
99    /// Encode the rating as a set of Nostr tags, ready to attach to an event.
100    ///
101    /// Returns a [`Tags`] value with one entry per numeric field plus a `z`
102    /// marker tag identifying the payload as a rating. Encoding is infallible
103    /// (nostr 0.45 `Tag::custom` takes string kind keys directly).
104    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        // Only when set: an absent `since` must leave the tag list exactly as
114        // it was before this field existed.
115        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    /// Rebuild a [`Rating`] from a set of Nostr tags previously produced by
128    /// [`Rating::to_tags`].
129    ///
130    /// Unknown tag keys are ignored so that the function keeps working if the
131    /// server adds new metadata fields. Returns a [`ServiceError`] if a
132    /// required key carries a non-parseable value.
133    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    /// A day-truncated timestamp: 2026-01-01T00:00:00Z.
202    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        // Arrange
218        let rating = sample();
219
220        // Act
221        let json = rating.as_json().expect("serializes");
222
223        // Assert — a Rating that never sets it must serialize exactly as it
224        // did before the field existed, or every existing consumer sees a new
225        // key appear.
226        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        // Arrange / Act
233        let rating = sample().with_since(SINCE);
234        let json = rating.as_json().expect("serializes");
235
236        // Assert
237        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        // Arrange — the shape every daemon published before this field.
244        let json =
245            r#"{"total_reviews":7,"total_rating":4.5,"last_rating":5,"max_rate":5,"min_rate":1}"#;
246
247        // Act
248        let rating = Rating::from_json(json).expect("parses");
249
250        // Assert
251        assert_eq!(rating.since, None);
252        assert_eq!(rating.total_reviews, 7);
253    }
254
255    #[test]
256    fn json_round_trips_with_since() {
257        // Arrange
258        let original = sample().with_since(SINCE);
259
260        // Act
261        let parsed = Rating::from_json(&original.as_json().expect("serializes")).expect("parses");
262
263        // Assert
264        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        // Arrange / Act
271        let tags = sample().to_tags();
272
273        // Assert — same reasoning as the JSON case: no new tag on an event
274        // built by a caller that has not adopted the field.
275        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        // Arrange / Act
282        let tags = sample().with_since(SINCE).to_tags();
283
284        // Assert
285        assert_eq!(tag_value(&tags, "since"), Some(SINCE.to_string()));
286        // The marker stays last, after the field that was inserted before it.
287        assert_eq!(tag_value(&tags, "z"), Some("rating".to_string()));
288    }
289
290    #[test]
291    fn tags_round_trip_with_since() {
292        // Arrange
293        let original = sample().with_since(SINCE);
294
295        // Act
296        let parsed = Rating::from_tags(original.to_tags()).expect("parses");
297
298        // Assert
299        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        // Arrange — an event from a daemon that has not shipped the field.
307        let tags = sample().to_tags();
308
309        // Act
310        let parsed = Rating::from_tags(tags).expect("parses");
311
312        // Assert
313        assert_eq!(parsed.since, None);
314    }
315
316    #[test]
317    fn with_since_truncates_to_the_start_of_the_utc_day() {
318        // Arrange / Act — a second-precision value would link the user's
319        // trade pubkeys, so it must not survive the builder.
320        let rating = sample().with_since(SINCE + 86_399);
321
322        // Assert
323        assert_eq!(rating.since, Some(SINCE));
324    }
325
326    #[test]
327    fn a_since_set_directly_is_truncated_on_the_way_out() {
328        // Arrange — the field is public, so the builder is not the only way in.
329        let mut rating = sample();
330        rating.since = Some(SINCE + 3_600);
331
332        // Act
333        let json = rating.as_json().expect("serializes");
334        let tags = rating.to_tags();
335
336        // Assert — both publication formats emit the day, not the second.
337        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        // Arrange — 1970 is a real date, so falling back to it would read as a
344        // user who has been trading for fifty years.
345        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        // Act
351        let parsed = Rating::from_tags(tags);
352
353        // Assert
354        assert!(parsed.is_err());
355    }
356}