Skip to main content

virustotal_rs/
comments.rs

1use crate::objects::{Collection, CollectionIterator, Object, ObjectOperations, ObjectResponse};
2use crate::{Client, Result};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Comment {
8    #[serde(flatten)]
9    pub object: Object<CommentAttributes>,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CommentAttributes {
14    pub text: String,
15
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub date: Option<i64>,
18
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub votes: Option<CommentVotes>,
21
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub html: Option<String>,
24
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub tags: Option<Vec<String>>,
27
28    #[serde(flatten)]
29    pub additional_attributes: HashMap<String, serde_json::Value>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct CommentVotes {
34    pub positive: u32,
35    pub negative: u32,
36    pub abuse: u32,
37}
38
39impl ObjectOperations for Comment {
40    type Attributes = CommentAttributes;
41
42    fn collection_name() -> &'static str {
43        "comments"
44    }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct CreateCommentRequest {
49    pub data: CreateCommentData,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct CreateCommentData {
54    #[serde(rename = "type")]
55    pub object_type: String,
56    pub attributes: CreateCommentAttributes,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct CreateCommentAttributes {
61    pub text: String,
62}
63
64impl CreateCommentRequest {
65    pub fn new(text: impl Into<String>) -> Self {
66        Self {
67            data: CreateCommentData {
68                object_type: "comment".to_string(),
69                attributes: CreateCommentAttributes { text: text.into() },
70            },
71        }
72    }
73}
74
75pub struct CommentIterator<'a> {
76    inner: CollectionIterator<'a, Comment>,
77}
78
79impl<'a> CommentIterator<'a> {
80    pub fn new(client: &'a crate::Client, url: impl Into<String>) -> Self {
81        Self {
82            inner: CollectionIterator::new(client, url),
83        }
84    }
85
86    pub fn with_limit(mut self, limit: u32) -> Self {
87        self.inner = self.inner.with_limit(limit);
88        self
89    }
90
91    pub async fn next_batch(&mut self) -> crate::Result<Vec<Comment>> {
92        self.inner.next_batch().await
93    }
94
95    pub async fn collect_all(self) -> crate::Result<Vec<Comment>> {
96        self.inner.collect_all().await
97    }
98}
99
100#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
101#[serde(rename_all = "lowercase")]
102pub enum CommentVoteType {
103    Positive,
104    Negative,
105    Abuse,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct VoteCommentRequest {
110    pub data: String,
111}
112
113impl VoteCommentRequest {
114    pub fn new(vote_type: CommentVoteType) -> Self {
115        let vote_str = match vote_type {
116            CommentVoteType::Positive => "positive",
117            CommentVoteType::Negative => "negative",
118            CommentVoteType::Abuse => "abuse",
119        };
120        Self {
121            data: vote_str.to_string(),
122        }
123    }
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct VoteCommentResponse {
128    pub data: CommentVotes,
129}
130
131/// Client for interacting with Comments API endpoints
132pub struct CommentsClient<'a> {
133    pub(crate) client: &'a Client,
134}
135
136impl<'a> CommentsClient<'a> {
137    pub fn new(client: &'a Client) -> Self {
138        Self { client }
139    }
140
141    /// Get latest comments with optional filtering
142    /// Filter examples: "tag:malware", "tag:phishing"
143    pub async fn get_latest(
144        &self,
145        filter: Option<&str>,
146        limit: Option<u32>,
147    ) -> Result<Collection<Comment>> {
148        let mut endpoint = "comments".to_string();
149        let mut params = Vec::new();
150
151        if let Some(f) = filter {
152            params.push(format!("filter={}", f));
153        }
154        if let Some(l) = limit {
155            params.push(format!("limit={}", l));
156        }
157
158        if !params.is_empty() {
159            endpoint.push('?');
160            endpoint.push_str(&params.join("&"));
161        }
162
163        self.client.get(&endpoint).await
164    }
165
166    /// Get latest comments iterator for paginated results
167    pub fn get_latest_iterator(&self, filter: Option<String>) -> CommentIterator<'_> {
168        let mut url = "comments".to_string();
169        if let Some(f) = filter {
170            url.push_str(&format!("?filter={}", f));
171        }
172        CommentIterator::new(self.client, url)
173    }
174
175    /// Get a specific comment by ID
176    pub async fn get(&self, comment_id: &str) -> Result<Comment> {
177        let url = Comment::object_url(comment_id);
178        let response: ObjectResponse<CommentAttributes> = self.client.get(&url).await?;
179        Ok(Comment {
180            object: response.data,
181        })
182    }
183
184    /// Delete a comment by ID
185    pub async fn delete(&self, comment_id: &str) -> Result<()> {
186        let url = Comment::object_url(comment_id);
187        self.client.delete(&url).await
188    }
189
190    /// Add a vote to a comment
191    pub async fn vote(
192        &self,
193        comment_id: &str,
194        vote_type: CommentVoteType,
195    ) -> Result<VoteCommentResponse> {
196        let endpoint = format!("{}/{}/vote", Comment::collection_name(), comment_id);
197        let request = VoteCommentRequest::new(vote_type);
198        self.client.post(&endpoint, &request).await
199    }
200
201    /// Get objects related to a comment
202    pub async fn get_relationship<T>(
203        &self,
204        comment_id: &str,
205        relationship: &str,
206    ) -> Result<Collection<T>>
207    where
208        T: for<'de> Deserialize<'de>,
209    {
210        let url = Comment::relationship_objects_url(comment_id, relationship);
211        self.client.get(&url).await
212    }
213
214    /// Get object descriptors related to a comment
215    pub async fn get_relationship_descriptors(
216        &self,
217        comment_id: &str,
218        relationship: &str,
219    ) -> Result<Collection<serde_json::Value>> {
220        let url = Comment::relationships_url(comment_id, relationship);
221        self.client.get(&url).await
222    }
223
224    /// Get relationship iterator for paginated results
225    pub fn get_relationship_iterator<T>(
226        &self,
227        comment_id: &str,
228        relationship: &str,
229    ) -> CollectionIterator<'_, T>
230    where
231        T: for<'de> Deserialize<'de> + Clone,
232    {
233        let url = Comment::relationship_objects_url(comment_id, relationship);
234        CollectionIterator::new(self.client, url)
235    }
236
237    /// Parse comment ID to extract item type and item ID
238    /// Returns (`item_type_char`, `item_id`, `random_string`)
239    pub fn parse_comment_id(comment_id: &str) -> Option<(char, String, String)> {
240        let parts: Vec<&str> = comment_id.splitn(3, '-').collect();
241        if parts.len() == 3 {
242            let item_type = parts[0].chars().next()?;
243            let item_id = parts[1].to_string();
244            let random_string = parts[2].to_string();
245            Some((item_type, item_id, random_string))
246        } else {
247            None
248        }
249    }
250
251    /// Get the item type from a comment ID
252    /// Returns: 'd' for domain, 'f' for file, 'g' for graph, 'i' for IP address, 'u' for URL
253    pub fn get_item_type(comment_id: &str) -> Option<char> {
254        comment_id.chars().next()
255    }
256}
257
258impl Client {
259    /// Get the Comments client for comment-related operations
260    pub fn comments(&self) -> CommentsClient<'_> {
261        CommentsClient::new(self)
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_create_comment_request() {
271        let request = CreateCommentRequest::new("This is a test comment");
272        assert_eq!(request.data.object_type, "comment");
273        assert_eq!(request.data.attributes.text, "This is a test comment");
274    }
275
276    #[test]
277    fn test_comment_vote_serialization() {
278        let vote = CommentVoteType::Positive;
279        let json = serde_json::to_string(&vote).unwrap();
280        assert_eq!(json, "\"positive\"");
281    }
282
283    #[test]
284    fn test_vote_comment_request() {
285        let request = VoteCommentRequest::new(CommentVoteType::Abuse);
286        assert_eq!(request.data, "abuse");
287    }
288
289    #[test]
290    fn test_comment_collection_name() {
291        assert_eq!(Comment::collection_name(), "comments");
292    }
293
294    #[test]
295    fn test_parse_comment_id() {
296        let comment_id = "f-abc123-random456";
297        let parsed = CommentsClient::parse_comment_id(comment_id);
298        assert!(parsed.is_some());
299        let (item_type, item_id, random_string) = parsed.unwrap();
300        assert_eq!(item_type, 'f');
301        assert_eq!(item_id, "abc123");
302        assert_eq!(random_string, "random456");
303    }
304
305    #[test]
306    fn test_parse_comment_id_domain() {
307        let comment_id = "d-example.com-xyz789";
308        let parsed = CommentsClient::parse_comment_id(comment_id);
309        assert!(parsed.is_some());
310        let (item_type, item_id, _) = parsed.unwrap();
311        assert_eq!(item_type, 'd');
312        assert_eq!(item_id, "example.com");
313    }
314
315    #[test]
316    fn test_parse_comment_id_url() {
317        let comment_id =
318            "u-011915942db556bbab5137f761efe61fed2b00598fea900360b800b193a7bf31-d94d7c8a";
319        let parsed = CommentsClient::parse_comment_id(comment_id);
320        assert!(parsed.is_some());
321        let (item_type, item_id, random_string) = parsed.unwrap();
322        assert_eq!(item_type, 'u');
323        assert_eq!(
324            item_id,
325            "011915942db556bbab5137f761efe61fed2b00598fea900360b800b193a7bf31"
326        );
327        assert_eq!(random_string, "d94d7c8a");
328    }
329
330    #[test]
331    fn test_get_item_type() {
332        assert_eq!(CommentsClient::get_item_type("f-abc123-random"), Some('f'));
333        assert_eq!(
334            CommentsClient::get_item_type("d-example.com-xyz"),
335            Some('d')
336        );
337        assert_eq!(
338            CommentsClient::get_item_type("i-192.168.1.1-abc"),
339            Some('i')
340        );
341        assert_eq!(CommentsClient::get_item_type("g-graph123-def"), Some('g'));
342        assert_eq!(CommentsClient::get_item_type("u-url123-ghi"), Some('u'));
343        assert_eq!(CommentsClient::get_item_type(""), None);
344    }
345
346    #[test]
347    fn test_comment_votes_structure() {
348        let votes = CommentVotes {
349            positive: 10,
350            negative: 2,
351            abuse: 1,
352        };
353        assert_eq!(votes.positive, 10);
354        assert_eq!(votes.negative, 2);
355        assert_eq!(votes.abuse, 1);
356    }
357
358    #[test]
359    fn test_vote_comment_response() {
360        let response = VoteCommentResponse {
361            data: CommentVotes {
362                positive: 5,
363                negative: 0,
364                abuse: 0,
365            },
366        };
367        assert_eq!(response.data.positive, 5);
368        assert_eq!(response.data.negative, 0);
369        assert_eq!(response.data.abuse, 0);
370    }
371}