mockforge_plugin_registry/
reviews.rs

1//! Plugin ratings and reviews system
2
3use crate::{RegistryError, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Plugin review
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Review {
10    /// Review ID
11    pub id: String,
12
13    /// Plugin name
14    pub plugin_name: String,
15
16    /// Plugin version reviewed
17    pub version: String,
18
19    /// User information
20    pub user: UserInfo,
21
22    /// Rating (1-5)
23    pub rating: u8,
24
25    /// Review text
26    pub comment: String,
27
28    /// Review title
29    pub title: Option<String>,
30
31    /// Helpful votes count
32    pub helpful_count: u32,
33
34    /// Unhelpful votes count
35    pub unhelpful_count: u32,
36
37    /// Verified purchase
38    pub verified: bool,
39
40    /// Created timestamp
41    pub created_at: String,
42
43    /// Updated timestamp
44    pub updated_at: String,
45
46    /// Response from author
47    pub author_response: Option<AuthorResponse>,
48}
49
50/// User information
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct UserInfo {
53    pub id: String,
54    pub name: String,
55    pub avatar_url: Option<String>,
56}
57
58/// Author response to review
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct AuthorResponse {
61    pub text: String,
62    pub created_at: String,
63}
64
65/// Review submission request
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct SubmitReviewRequest {
68    pub plugin_name: String,
69    pub version: String,
70    pub rating: u8,
71    pub title: Option<String>,
72    pub comment: String,
73}
74
75impl SubmitReviewRequest {
76    /// Validate review request
77    pub fn validate(&self) -> Result<()> {
78        if self.rating < 1 || self.rating > 5 {
79            return Err(RegistryError::InvalidManifest(
80                "Rating must be between 1 and 5".to_string(),
81            ));
82        }
83
84        if self.comment.len() < 10 {
85            return Err(RegistryError::InvalidManifest(
86                "Review comment must be at least 10 characters".to_string(),
87            ));
88        }
89
90        if self.comment.len() > 5000 {
91            return Err(RegistryError::InvalidManifest(
92                "Review comment must be less than 5000 characters".to_string(),
93            ));
94        }
95
96        if let Some(title) = &self.title {
97            if title.len() > 100 {
98                return Err(RegistryError::InvalidManifest(
99                    "Review title must be less than 100 characters".to_string(),
100                ));
101            }
102        }
103
104        Ok(())
105    }
106}
107
108/// Review update request
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct UpdateReviewRequest {
111    pub rating: Option<u8>,
112    pub title: Option<String>,
113    pub comment: Option<String>,
114}
115
116/// Vote on review helpfulness
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct VoteRequest {
119    pub helpful: bool,
120}
121
122/// Review statistics
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ReviewStats {
125    pub total_reviews: u32,
126    pub average_rating: f64,
127    pub rating_distribution: HashMap<u8, u32>,
128}
129
130impl ReviewStats {
131    /// Create empty stats
132    pub fn empty() -> Self {
133        Self {
134            total_reviews: 0,
135            average_rating: 0.0,
136            rating_distribution: HashMap::new(),
137        }
138    }
139
140    /// Calculate from reviews
141    pub fn from_reviews(reviews: &[Review]) -> Self {
142        let mut distribution = HashMap::new();
143        let mut total_rating = 0u32;
144
145        for review in reviews {
146            *distribution.entry(review.rating).or_insert(0) += 1;
147            total_rating += review.rating as u32;
148        }
149
150        let average_rating = if reviews.is_empty() {
151            0.0
152        } else {
153            total_rating as f64 / reviews.len() as f64
154        };
155
156        Self {
157            total_reviews: reviews.len() as u32,
158            average_rating,
159            rating_distribution: distribution,
160        }
161    }
162}
163
164/// Query reviews for a plugin
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ReviewQuery {
167    pub plugin_name: String,
168    pub version: Option<String>,
169    pub min_rating: Option<u8>,
170    pub sort_by: ReviewSortOrder,
171    pub page: usize,
172    pub per_page: usize,
173}
174
175impl Default for ReviewQuery {
176    fn default() -> Self {
177        Self {
178            plugin_name: String::new(),
179            version: None,
180            min_rating: None,
181            sort_by: ReviewSortOrder::MostHelpful,
182            page: 0,
183            per_page: 20,
184        }
185    }
186}
187
188/// Review sort order
189#[derive(Debug, Clone, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum ReviewSortOrder {
192    MostHelpful,
193    MostRecent,
194    HighestRated,
195    LowestRated,
196}
197
198/// Review search results
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct ReviewResults {
201    pub reviews: Vec<Review>,
202    pub stats: ReviewStats,
203    pub total: usize,
204    pub page: usize,
205    pub per_page: usize,
206}
207
208/// Review moderation action
209#[derive(Debug, Clone, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum ModerationAction {
212    Approve,
213    Reject,
214    Flag,
215    Delete,
216}
217
218/// Moderation request
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct ModerationRequest {
221    pub action: ModerationAction,
222    pub reason: Option<String>,
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_review_validation() {
231        let valid = SubmitReviewRequest {
232            plugin_name: "test-plugin".to_string(),
233            version: "1.0.0".to_string(),
234            rating: 4,
235            title: Some("Great plugin!".to_string()),
236            comment: "This plugin works great for my use case.".to_string(),
237        };
238        assert!(valid.validate().is_ok());
239
240        let invalid_rating = SubmitReviewRequest {
241            rating: 6,
242            ..valid.clone()
243        };
244        assert!(invalid_rating.validate().is_err());
245
246        let short_comment = SubmitReviewRequest {
247            comment: "Too short".to_string(),
248            ..valid.clone()
249        };
250        assert!(short_comment.validate().is_err());
251    }
252
253    #[test]
254    fn test_review_stats_calculation() {
255        let reviews = vec![
256            Review {
257                id: "1".to_string(),
258                plugin_name: "test".to_string(),
259                version: "1.0.0".to_string(),
260                user: UserInfo {
261                    id: "u1".to_string(),
262                    name: "User 1".to_string(),
263                    avatar_url: None,
264                },
265                rating: 5,
266                comment: "Excellent!".to_string(),
267                title: None,
268                helpful_count: 10,
269                unhelpful_count: 0,
270                verified: true,
271                created_at: "2025-01-01T00:00:00Z".to_string(),
272                updated_at: "2025-01-01T00:00:00Z".to_string(),
273                author_response: None,
274            },
275            Review {
276                id: "2".to_string(),
277                plugin_name: "test".to_string(),
278                version: "1.0.0".to_string(),
279                user: UserInfo {
280                    id: "u2".to_string(),
281                    name: "User 2".to_string(),
282                    avatar_url: None,
283                },
284                rating: 3,
285                comment: "It's okay".to_string(),
286                title: None,
287                helpful_count: 5,
288                unhelpful_count: 2,
289                verified: false,
290                created_at: "2025-01-02T00:00:00Z".to_string(),
291                updated_at: "2025-01-02T00:00:00Z".to_string(),
292                author_response: None,
293            },
294        ];
295
296        let stats = ReviewStats::from_reviews(&reviews);
297        assert_eq!(stats.total_reviews, 2);
298        assert_eq!(stats.average_rating, 4.0);
299        assert_eq!(stats.rating_distribution.get(&5), Some(&1));
300        assert_eq!(stats.rating_distribution.get(&3), Some(&1));
301    }
302}