chie_shared/types/
content.rs

1//! Content metadata types for CHIE Protocol.
2
3#[cfg(feature = "schema")]
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use super::core::*;
8use super::enums::{ContentCategory, ContentStatus};
9use super::validation::ValidationError;
10
11/// Content metadata.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[cfg_attr(feature = "schema", derive(JsonSchema))]
14pub struct ContentMetadata {
15    pub id: uuid::Uuid,
16    pub cid: ContentCid,
17    pub title: String,
18    pub description: String,
19    pub category: ContentCategory,
20    pub tags: Vec<String>,
21    pub size_bytes: Bytes,
22    pub chunk_count: u64,
23    pub price: Points,
24    pub creator_id: uuid::Uuid,
25    pub status: ContentStatus,
26    pub preview_images: Vec<String>,
27    pub created_at: chrono::DateTime<chrono::Utc>,
28    pub updated_at: chrono::DateTime<chrono::Utc>,
29}
30
31impl ContentMetadata {
32    /// Validate content metadata.
33    ///
34    /// # Errors
35    ///
36    /// Returns `Vec<ValidationError>` with all validation errors found
37    pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
38        let mut errors = Vec::new();
39
40        // Validate CID
41        if self.cid.is_empty() {
42            errors.push(ValidationError::EmptyCid);
43        }
44
45        // Validate title
46        if self.title.len() > MAX_TITLE_LENGTH {
47            errors.push(ValidationError::TitleTooLong {
48                length: self.title.len(),
49                max: MAX_TITLE_LENGTH,
50            });
51        }
52
53        // Validate description
54        if self.description.len() > MAX_DESCRIPTION_LENGTH {
55            errors.push(ValidationError::DescriptionTooLong {
56                length: self.description.len(),
57                max: MAX_DESCRIPTION_LENGTH,
58            });
59        }
60
61        // Validate tags
62        if self.tags.len() > MAX_TAGS_COUNT {
63            errors.push(ValidationError::TooManyTags {
64                count: self.tags.len(),
65                max: MAX_TAGS_COUNT,
66            });
67        }
68        for tag in &self.tags {
69            if tag.len() > MAX_TAG_LENGTH {
70                errors.push(ValidationError::TagTooLong {
71                    tag: tag.clone(),
72                    length: tag.len(),
73                    max: MAX_TAG_LENGTH,
74                });
75            }
76        }
77
78        // Validate size
79        if self.size_bytes < MIN_CONTENT_SIZE || self.size_bytes > MAX_CONTENT_SIZE {
80            errors.push(ValidationError::ContentSizeOutOfBounds {
81                size: self.size_bytes,
82                min: MIN_CONTENT_SIZE,
83                max: MAX_CONTENT_SIZE,
84            });
85        }
86
87        if errors.is_empty() {
88            Ok(())
89        } else {
90            Err(errors)
91        }
92    }
93
94    /// Check if metadata is valid.
95    #[inline]
96    pub fn is_valid(&self) -> bool {
97        self.validate().is_ok()
98    }
99
100    /// Calculate expected chunk count from size.
101    #[inline]
102    pub fn expected_chunk_count(&self) -> u64 {
103        self.size_bytes.div_ceil(CHUNK_SIZE as u64)
104    }
105
106    /// Get the content size in megabytes.
107    #[inline]
108    pub fn size_mb(&self) -> f64 {
109        self.size_bytes as f64 / (1024.0 * 1024.0)
110    }
111
112    /// Get the content size in gigabytes.
113    #[inline]
114    pub fn size_gb(&self) -> f64 {
115        self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
116    }
117
118    /// Check if the content is active and available.
119    #[inline]
120    pub fn is_active(&self) -> bool {
121        self.status == ContentStatus::Active
122    }
123
124    /// Check if the content is being processed.
125    #[inline]
126    pub fn is_processing(&self) -> bool {
127        self.status == ContentStatus::Processing
128    }
129
130    /// Check if the content has been removed or rejected.
131    #[inline]
132    pub fn is_unavailable(&self) -> bool {
133        matches!(
134            self.status,
135            ContentStatus::Removed | ContentStatus::Rejected
136        )
137    }
138}
139
140/// Builder for ContentMetadata.
141#[derive(Debug, Default)]
142pub struct ContentMetadataBuilder {
143    id: Option<uuid::Uuid>,
144    cid: Option<ContentCid>,
145    title: Option<String>,
146    description: String,
147    category: ContentCategory,
148    tags: Vec<String>,
149    size_bytes: Bytes,
150    chunk_count: Option<u64>,
151    price: Points,
152    creator_id: Option<uuid::Uuid>,
153    status: ContentStatus,
154    preview_images: Vec<String>,
155}
156
157impl ContentMetadataBuilder {
158    /// Create a new builder.
159    pub fn new() -> Self {
160        Self {
161            category: ContentCategory::Other,
162            status: ContentStatus::Processing,
163            ..Default::default()
164        }
165    }
166
167    /// Set the ID (auto-generated if not set).
168    pub fn id(mut self, id: uuid::Uuid) -> Self {
169        self.id = Some(id);
170        self
171    }
172
173    /// Set the CID.
174    pub fn cid(mut self, cid: impl Into<String>) -> Self {
175        self.cid = Some(cid.into());
176        self
177    }
178
179    /// Set the title.
180    pub fn title(mut self, title: impl Into<String>) -> Self {
181        self.title = Some(title.into());
182        self
183    }
184
185    /// Set the description.
186    pub fn description(mut self, description: impl Into<String>) -> Self {
187        self.description = description.into();
188        self
189    }
190
191    /// Set the category.
192    pub fn category(mut self, category: ContentCategory) -> Self {
193        self.category = category;
194        self
195    }
196
197    /// Set the tags.
198    pub fn tags(mut self, tags: Vec<String>) -> Self {
199        self.tags = tags;
200        self
201    }
202
203    /// Add a tag.
204    pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
205        self.tags.push(tag.into());
206        self
207    }
208
209    /// Set the size in bytes.
210    pub fn size_bytes(mut self, size: Bytes) -> Self {
211        self.size_bytes = size;
212        self
213    }
214
215    /// Set the chunk count (auto-calculated if not set).
216    pub fn chunk_count(mut self, count: u64) -> Self {
217        self.chunk_count = Some(count);
218        self
219    }
220
221    /// Set the price.
222    pub fn price(mut self, price: Points) -> Self {
223        self.price = price;
224        self
225    }
226
227    /// Set the creator ID.
228    pub fn creator_id(mut self, creator_id: uuid::Uuid) -> Self {
229        self.creator_id = Some(creator_id);
230        self
231    }
232
233    /// Set the status.
234    pub fn status(mut self, status: ContentStatus) -> Self {
235        self.status = status;
236        self
237    }
238
239    /// Set preview images.
240    pub fn preview_images(mut self, images: Vec<String>) -> Self {
241        self.preview_images = images;
242        self
243    }
244
245    /// Build the ContentMetadata.
246    ///
247    /// # Errors
248    ///
249    /// Returns error if required fields (cid, title) are missing
250    pub fn build(self) -> Result<ContentMetadata, &'static str> {
251        let now = chrono::Utc::now();
252        let size_bytes = self.size_bytes;
253        let chunk_count = self
254            .chunk_count
255            .unwrap_or_else(|| size_bytes.div_ceil(CHUNK_SIZE as u64));
256
257        Ok(ContentMetadata {
258            id: self.id.unwrap_or_else(uuid::Uuid::new_v4),
259            cid: self.cid.ok_or("cid is required")?,
260            title: self.title.ok_or("title is required")?,
261            description: self.description,
262            category: self.category,
263            tags: self.tags,
264            size_bytes,
265            chunk_count,
266            price: self.price,
267            creator_id: self.creator_id.ok_or("creator_id is required")?,
268            status: self.status,
269            preview_images: self.preview_images,
270            created_at: now,
271            updated_at: now,
272        })
273    }
274}
275
276/// Content investment recommendation.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278#[cfg_attr(feature = "schema", derive(JsonSchema))]
279pub struct ContentInvestment {
280    pub content_id: uuid::Uuid,
281    pub cid: ContentCid,
282    pub title: String,
283    pub current_seeders: u64,
284    pub demand_level: super::enums::DemandLevel,
285    pub predicted_revenue_per_gb: f64,
286    pub recommended_allocation_gb: f64,
287}
288
289/// Content statistics for creators.
290#[derive(Debug, Clone, Serialize, Deserialize)]
291#[cfg_attr(feature = "schema", derive(JsonSchema))]
292pub struct ContentStats {
293    /// Content ID.
294    pub content_id: uuid::Uuid,
295    /// Total downloads.
296    pub download_count: u64,
297    /// Total bandwidth served (bytes).
298    pub bandwidth_served: Bytes,
299    /// Number of active seeders.
300    pub active_seeders: u64,
301    /// Total earnings from this content.
302    pub total_earnings: Points,
303    /// Views count.
304    pub views: u64,
305    /// Unique downloaders.
306    pub unique_downloaders: u64,
307    /// Average rating (0.0-5.0).
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub average_rating: Option<f64>,
310    /// Statistics last updated.
311    pub updated_at: chrono::DateTime<chrono::Utc>,
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_content_metadata_builder() {
320        let creator_id = uuid::Uuid::new_v4();
321        let metadata = ContentMetadataBuilder::new()
322            .cid("QmTestContent")
323            .title("Test Content")
324            .description("A test content item")
325            .category(ContentCategory::ThreeDModels)
326            .add_tag("blender")
327            .add_tag("lowpoly")
328            .size_bytes(1024 * 1024)
329            .price(100)
330            .creator_id(creator_id)
331            .build()
332            .unwrap();
333
334        assert_eq!(metadata.cid, "QmTestContent");
335        assert_eq!(metadata.title, "Test Content");
336        assert_eq!(metadata.category, ContentCategory::ThreeDModels);
337        assert_eq!(metadata.tags.len(), 2);
338        assert_eq!(metadata.chunk_count, metadata.expected_chunk_count());
339        assert!(metadata.is_valid());
340    }
341
342    #[test]
343    fn test_content_metadata_validation_title_too_long() {
344        let creator_id = uuid::Uuid::new_v4();
345        let long_title = "a".repeat(MAX_TITLE_LENGTH + 1);
346        let metadata = ContentMetadataBuilder::new()
347            .cid("QmTest")
348            .title(long_title)
349            .creator_id(creator_id)
350            .size_bytes(10000)
351            .build()
352            .unwrap();
353
354        let result = metadata.validate();
355        assert!(result.is_err());
356        let errors = result.unwrap_err();
357        assert!(
358            errors
359                .iter()
360                .any(|e| matches!(e, ValidationError::TitleTooLong { .. }))
361        );
362    }
363
364    #[test]
365    fn test_content_metadata_validation_too_many_tags() {
366        let creator_id = uuid::Uuid::new_v4();
367        let mut builder = ContentMetadataBuilder::new()
368            .cid("QmTest")
369            .title("Test")
370            .creator_id(creator_id)
371            .size_bytes(10000);
372
373        for i in 0..(MAX_TAGS_COUNT + 1) {
374            builder = builder.add_tag(format!("tag{}", i));
375        }
376
377        let metadata = builder.build().unwrap();
378        let result = metadata.validate();
379        assert!(result.is_err());
380        let errors = result.unwrap_err();
381        assert!(
382            errors
383                .iter()
384                .any(|e| matches!(e, ValidationError::TooManyTags { .. }))
385        );
386    }
387
388    #[test]
389    fn test_content_metadata_validation_size_out_of_bounds() {
390        let creator_id = uuid::Uuid::new_v4();
391
392        // Too small
393        let metadata_small = ContentMetadataBuilder::new()
394            .cid("QmTest")
395            .title("Test")
396            .creator_id(creator_id)
397            .size_bytes(100) // Below MIN_CONTENT_SIZE
398            .build()
399            .unwrap();
400
401        let result = metadata_small.validate();
402        assert!(result.is_err());
403
404        // Too large
405        let metadata_large = ContentMetadataBuilder::new()
406            .cid("QmTest")
407            .title("Test")
408            .creator_id(creator_id)
409            .size_bytes(MAX_CONTENT_SIZE + 1)
410            .build()
411            .unwrap();
412
413        let result = metadata_large.validate();
414        assert!(result.is_err());
415    }
416
417    #[test]
418    fn test_content_metadata_expected_chunk_count() {
419        let creator_id = uuid::Uuid::new_v4();
420        let size = CHUNK_SIZE as u64 * 5 + 1000; // 5 full chunks + partial
421        let metadata = ContentMetadataBuilder::new()
422            .cid("QmTest")
423            .title("Test")
424            .creator_id(creator_id)
425            .size_bytes(size)
426            .build()
427            .unwrap();
428
429        assert_eq!(metadata.expected_chunk_count(), 6);
430    }
431
432    #[test]
433    fn test_content_metadata_serialization() {
434        let metadata = ContentMetadataBuilder::new()
435            .cid("QmTest")
436            .title("Test")
437            .creator_id(uuid::Uuid::new_v4())
438            .size_bytes(10000)
439            .build()
440            .unwrap();
441
442        let json = serde_json::to_string(&metadata).unwrap();
443        let deserialized: ContentMetadata = serde_json::from_str(&json).unwrap();
444        assert_eq!(metadata.cid, deserialized.cid);
445        assert_eq!(metadata.title, deserialized.title);
446    }
447}