1#![allow(clippy::new_without_default)]
7
8use crate::{client::WebClient, error::WebToolError};
9use chrono::{DateTime, Utc};
10use riglr_core::util::get_env_or_default;
11use riglr_macros::tool;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use tracing::{debug, info};
16
17mod api_types {
19 use serde::{Deserialize, Serialize};
20
21 #[derive(Debug, Deserialize, Serialize)]
22 pub struct ApiResponseRaw {
23 pub data: Option<Vec<TweetRaw>>,
24 pub includes: Option<IncludesRaw>,
25 pub meta: Option<MetaRaw>,
26 pub errors: Option<Vec<ErrorRaw>>,
27 }
28
29 #[derive(Debug, Deserialize, Serialize)]
30 pub struct TweetRaw {
31 pub id: String,
32 pub text: String,
33 pub author_id: Option<String>,
34 pub created_at: Option<String>,
35 pub lang: Option<String>,
36 pub public_metrics: Option<PublicMetricsRaw>,
37 pub entities: Option<EntitiesRaw>,
38 pub context_annotations: Option<Vec<ContextAnnotationRaw>>,
39 pub referenced_tweets: Option<Vec<ReferencedTweetRaw>>,
40 }
41
42 #[derive(Debug, Clone, Deserialize, Serialize)]
43 pub struct UserRaw {
44 pub id: String,
45 pub username: String,
46 pub name: String,
47 pub description: Option<String>,
48 pub public_metrics: Option<UserMetricsRaw>,
49 pub verified: Option<bool>,
50 pub created_at: Option<String>,
51 }
52
53 #[derive(Debug, Deserialize, Serialize)]
54 pub struct IncludesRaw {
55 pub users: Option<Vec<UserRaw>>,
56 pub tweets: Option<Vec<TweetRaw>>,
57 }
58
59 #[derive(Debug, Deserialize, Serialize)]
60 pub struct PublicMetricsRaw {
61 pub retweet_count: Option<u32>,
62 pub reply_count: Option<u32>,
63 pub like_count: Option<u32>,
64 pub quote_count: Option<u32>,
65 pub impression_count: Option<u32>,
66 }
67
68 #[derive(Debug, Clone, Deserialize, Serialize)]
69 pub struct UserMetricsRaw {
70 pub followers_count: Option<u32>,
71 pub following_count: Option<u32>,
72 pub tweet_count: Option<u32>,
73 pub listed_count: Option<u32>,
74 }
75
76 #[derive(Debug, Deserialize, Serialize)]
77 pub struct EntitiesRaw {
78 pub hashtags: Option<Vec<HashtagRaw>>,
79 pub mentions: Option<Vec<MentionRaw>>,
80 pub urls: Option<Vec<UrlRaw>>,
81 pub cashtags: Option<Vec<CashtagRaw>>,
82 }
83
84 #[derive(Debug, Deserialize, Serialize)]
85 pub struct HashtagRaw {
86 pub tag: String,
87 }
88
89 #[derive(Debug, Deserialize, Serialize)]
90 pub struct MentionRaw {
91 pub username: String,
92 }
93
94 #[derive(Debug, Deserialize, Serialize)]
95 pub struct UrlRaw {
96 pub expanded_url: Option<String>,
97 pub url: String,
98 }
99
100 #[derive(Debug, Deserialize, Serialize)]
101 pub struct CashtagRaw {
102 pub tag: String,
103 }
104
105 #[derive(Debug, Deserialize, Serialize)]
106 pub struct ContextAnnotationRaw {
107 pub domain: DomainRaw,
108 pub entity: EntityRaw,
109 }
110
111 #[derive(Debug, Deserialize, Serialize)]
112 pub struct DomainRaw {
113 pub id: String,
114 pub name: Option<String>,
115 pub description: Option<String>,
116 }
117
118 #[derive(Debug, Deserialize, Serialize)]
119 pub struct EntityRaw {
120 pub id: String,
121 pub name: Option<String>,
122 pub description: Option<String>,
123 }
124
125 #[derive(Debug, Deserialize, Serialize)]
126 pub struct ReferencedTweetRaw {
127 pub r#type: String,
128 pub id: String,
129 }
130
131 #[derive(Debug, Deserialize, Serialize)]
132 pub struct MetaRaw {
133 pub result_count: Option<u32>,
134 pub next_token: Option<String>,
135 pub previous_token: Option<String>,
136 }
137
138 #[derive(Debug, Deserialize, Serialize)]
139 pub struct ErrorRaw {
140 pub title: String,
141 pub detail: Option<String>,
142 pub r#type: Option<String>,
143 }
144}
145
146const TWITTER_BEARER_TOKEN: &str = "TWITTER_BEARER_TOKEN";
148
149#[derive(Debug, Clone)]
151pub struct TwitterConfig {
152 pub bearer_token: String,
154 pub base_url: String,
156 pub max_results: u32,
158 pub rate_limit_window: u64,
160 pub max_requests_per_window: u32,
162}
163
164pub struct TwitterTool {
166 #[allow(dead_code)]
167 config: TwitterConfig,
168}
169
170impl TwitterTool {
171 pub fn new(config: TwitterConfig) -> Self {
173 Self { config }
174 }
175
176 pub fn from_bearer_token(bearer_token: String) -> Self {
178 Self::new(TwitterConfig {
179 bearer_token,
180 base_url: "https://api.twitter.com/2".to_string(),
181 max_results: 100,
182 rate_limit_window: 900,
183 max_requests_per_window: 300,
184 })
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
190pub struct TwitterPost {
191 pub id: String,
193 pub text: String,
195 pub author: TwitterUser,
197 pub created_at: DateTime<Utc>,
199 pub metrics: TweetMetrics,
201 pub entities: TweetEntities,
203 pub lang: Option<String>,
205 pub is_reply: bool,
207 pub is_retweet: bool,
209 pub context_annotations: Vec<ContextAnnotation>,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
215pub struct TwitterUser {
216 pub id: String,
218 pub username: String,
220 pub name: String,
222 pub description: Option<String>,
224 pub followers_count: u32,
226 pub following_count: u32,
228 pub tweet_count: u32,
230 pub verified: bool,
232 pub created_at: DateTime<Utc>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
238pub struct TweetMetrics {
239 pub retweet_count: u32,
241 pub like_count: u32,
243 pub reply_count: u32,
245 pub quote_count: u32,
247 pub impression_count: Option<u32>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
253pub struct TweetEntities {
254 pub hashtags: Vec<String>,
256 pub mentions: Vec<String>,
258 pub urls: Vec<String>,
260 pub cashtags: Vec<String>,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
266pub struct ContextAnnotation {
267 pub domain_id: String,
269 pub domain_name: String,
271 pub entity_id: String,
273 pub entity_name: String,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
279pub struct TwitterSearchResult {
280 pub tweets: Vec<TwitterPost>,
282 pub meta: SearchMetadata,
284 pub rate_limit_info: RateLimitInfo,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
290pub struct SearchMetadata {
291 pub result_count: u32,
293 pub query: String,
295 pub next_token: Option<String>,
297 pub searched_at: DateTime<Utc>,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
303pub struct RateLimitInfo {
304 pub remaining: u32,
306 pub limit: u32,
308 pub reset_at: u64,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
314pub struct SentimentAnalysis {
315 pub overall_sentiment: f64,
317 pub sentiment_breakdown: SentimentBreakdown,
319 pub tweet_count: u32,
321 pub analyzed_at: DateTime<Utc>,
323 pub top_positive_tweets: Vec<TwitterPost>,
325 pub top_negative_tweets: Vec<TwitterPost>,
327 pub top_entities: Vec<EntityMention>,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
333pub struct SentimentBreakdown {
334 pub positive_pct: f64,
336 pub neutral_pct: f64,
338 pub negative_pct: f64,
340 pub positive_avg_engagement: f64,
342 pub negative_avg_engagement: f64,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
348pub struct EntityMention {
349 pub name: String,
351 pub mention_count: u32,
353 pub avg_sentiment: f64,
355}
356
357impl TwitterConfig {
358 pub fn new(bearer_token: String) -> Self {
360 Self {
361 bearer_token,
362 base_url: "https://api.twitter.com/2".to_string(),
363 max_results: 100,
364 rate_limit_window: 900, max_requests_per_window: 300,
366 }
367 }
368
369 pub fn with_base_url(mut self, base_url: String) -> Self {
371 self.base_url = base_url;
372 self
373 }
374}
375
376#[tool]
381pub async fn search_tweets(
382 _context: &riglr_core::provider::ApplicationContext,
383 query: String,
384 max_results: Option<u32>,
385 include_sentiment: Option<bool>,
386 language: Option<String>,
387 start_time: Option<String>,
388 end_time: Option<String>,
389) -> crate::error::Result<TwitterSearchResult> {
390 debug!(
391 "Searching Twitter for: '{}' (max: {})",
392 query,
393 max_results.unwrap_or(100)
394 );
395
396 let bearer_token = get_env_or_default(TWITTER_BEARER_TOKEN, "");
399 if bearer_token.is_empty() {
400 return Err(WebToolError::Api(
401 "Twitter bearer token not configured. Set TWITTER_BEARER_TOKEN environment variable or use configuration injection".to_string(),
402 ));
403 }
404 let config = TwitterConfig::new(bearer_token);
405
406 let client = WebClient::default().with_twitter_token(config.bearer_token.clone());
407
408 let mut params = HashMap::new();
410 params.insert("query".to_string(), query.clone());
411 params.insert(
412 "max_results".to_string(),
413 max_results.unwrap_or(100).to_string(),
414 );
415
416 params.insert(
418 "tweet.fields".to_string(),
419 "created_at,author_id,public_metrics,lang,entities,context_annotations,in_reply_to_user_id"
420 .to_string(),
421 );
422 params.insert(
423 "user.fields".to_string(),
424 "username,name,description,public_metrics,verified,created_at".to_string(),
425 );
426 params.insert("expansions".to_string(), "author_id".to_string());
427
428 if let Some(lang) = language {
429 params.insert("lang".to_string(), lang);
430 }
431
432 if let Some(start) = start_time {
433 params.insert("start_time".to_string(), start);
434 }
435
436 if let Some(end) = end_time {
437 params.insert("end_time".to_string(), end);
438 }
439
440 let url = format!("{}/tweets/search/recent", config.base_url);
442 let response = client.get_with_params(&url, ¶ms).await.map_err(|e| {
443 if e.to_string().contains("timeout") || e.to_string().contains("connection") {
444 WebToolError::Network(format!("Twitter API request failed: {}", e))
445 } else {
446 WebToolError::Api(format!("Twitter API request failed: {}", e))
447 }
448 })?;
449
450 let tweets = parse_twitter_response(&response)
452 .await
453 .map_err(|e| WebToolError::Api(format!("Failed to parse Twitter response: {}", e)))?;
454
455 let analyzed_tweets = if include_sentiment.unwrap_or(false) {
457 analyze_tweet_sentiment(&tweets)
458 .await
459 .map_err(|e| WebToolError::Api(format!("Sentiment analysis failed: {}", e)))?
460 } else {
461 tweets
462 };
463
464 let result = TwitterSearchResult {
465 tweets: analyzed_tweets.clone(),
466 meta: SearchMetadata {
467 result_count: analyzed_tweets.len() as u32,
468 query: query.clone(),
469 next_token: None, searched_at: Utc::now(),
471 },
472 rate_limit_info: RateLimitInfo {
473 remaining: 299, limit: 300,
475 reset_at: (Utc::now().timestamp() + 900) as u64,
476 },
477 };
478
479 info!(
480 "Twitter search completed: {} tweets found for '{}'",
481 result.tweets.len(),
482 query
483 );
484
485 Ok(result)
486}
487
488#[tool]
492pub async fn get_user_tweets(
493 _context: &riglr_core::provider::ApplicationContext,
494 username: String,
495 max_results: Option<u32>,
496 include_replies: Option<bool>,
497 include_retweets: Option<bool>,
498) -> crate::error::Result<Vec<TwitterPost>> {
499 debug!(
500 "Fetching tweets from user: @{} (max: {})",
501 username,
502 max_results.unwrap_or(10)
503 );
504
505 let bearer_token = get_env_or_default(TWITTER_BEARER_TOKEN, "");
508 if bearer_token.is_empty() {
509 return Err(WebToolError::Api(
510 "Twitter bearer token not configured. Set TWITTER_BEARER_TOKEN environment variable or use configuration injection".to_string(),
511 ));
512 }
513 let config = TwitterConfig::new(bearer_token);
514
515 let client = WebClient::default().with_twitter_token(config.bearer_token.clone());
516
517 let user_url = format!("{}/users/by/username/{}", config.base_url, username);
519 let _user_response = client.get(&user_url).await.map_err(|e| {
520 if e.to_string().contains("404") {
521 WebToolError::Api(format!("User @{} not found", username))
522 } else if e.to_string().contains("timeout") {
523 WebToolError::Network(format!("Failed to get user info: {}", e))
524 } else {
525 WebToolError::Api(format!("Failed to get user info: {}", e))
526 }
527 })?;
528
529 let user_id = "123456789"; let mut params = HashMap::new();
534 params.insert(
535 "max_results".to_string(),
536 max_results.unwrap_or(10).to_string(),
537 );
538 params.insert(
539 "tweet.fields".to_string(),
540 "created_at,public_metrics,lang,entities,context_annotations".to_string(),
541 );
542
543 if !include_replies.unwrap_or(true) {
544 params.insert("exclude".to_string(), "replies".to_string());
545 }
546
547 if !include_retweets.unwrap_or(true) {
548 params.insert("exclude".to_string(), "retweets".to_string());
549 }
550
551 let tweets_url = format!("{}/users/{}/tweets", config.base_url, user_id);
552 let response = client.get_with_params(&tweets_url, ¶ms).await?;
553
554 let tweets = parse_twitter_response(&response)
555 .await
556 .map_err(|e| WebToolError::Api(format!("Failed to parse Twitter response: {}", e)))?;
557
558 info!("Retrieved {} tweets from @{}", tweets.len(), username);
559
560 Ok(tweets)
561}
562
563#[tool]
568pub async fn analyze_crypto_sentiment(
569 context: &riglr_core::provider::ApplicationContext,
570 token_symbol: String,
571 time_window_hours: Option<u32>,
572 min_engagement: Option<u32>,
573) -> crate::error::Result<SentimentAnalysis> {
574 debug!(
575 "Analyzing sentiment for ${} over {} hours",
576 token_symbol,
577 time_window_hours.unwrap_or(24)
578 );
579
580 let _hours = time_window_hours.unwrap_or(24);
581 let min_engagement_threshold = min_engagement.unwrap_or(10);
582
583 let search_query = format!("${} OR {} -is:retweet lang:en", token_symbol, token_symbol);
585
586 let search_result = search_tweets(
588 context,
589 search_query,
590 Some(500), Some(false), Some("en".to_string()),
593 None, None,
595 )
596 .await?;
597
598 let filtered_tweets: Vec<TwitterPost> = search_result
600 .tweets
601 .into_iter()
602 .filter(|tweet| {
603 let total_engagement =
604 tweet.metrics.like_count + tweet.metrics.retweet_count + tweet.metrics.reply_count;
605 total_engagement >= min_engagement_threshold
606 })
607 .collect();
608
609 let sentiment_scores = analyze_tweet_sentiment_scores(&filtered_tweets)
611 .await
612 .map_err(|e| WebToolError::Api(format!("Failed to analyze sentiment: {}", e)))?;
613
614 let overall_sentiment = sentiment_scores.iter().sum::<f64>() / sentiment_scores.len() as f64;
615
616 let positive_count = sentiment_scores.iter().filter(|&&s| s > 0.1).count();
618 let negative_count = sentiment_scores.iter().filter(|&&s| s < -0.1).count();
619 let neutral_count = sentiment_scores.len() - positive_count - negative_count;
620
621 let total = sentiment_scores.len() as f64;
622 let sentiment_breakdown = SentimentBreakdown {
623 positive_pct: (positive_count as f64 / total) * 100.0,
624 neutral_pct: (neutral_count as f64 / total) * 100.0,
625 negative_pct: (negative_count as f64 / total) * 100.0,
626 positive_avg_engagement: 0.0, negative_avg_engagement: 0.0,
628 };
629
630 let mut tweets_with_sentiment: Vec<(TwitterPost, f64)> = filtered_tweets
632 .into_iter()
633 .zip(sentiment_scores.iter())
634 .map(|(tweet, &score)| (tweet, score))
635 .collect();
636
637 tweets_with_sentiment
638 .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
639
640 let top_positive_tweets = tweets_with_sentiment
641 .iter()
642 .filter(|(_, score)| *score > 0.0)
643 .take(5)
644 .map(|(tweet, _)| tweet.clone())
645 .collect();
646
647 let top_negative_tweets = tweets_with_sentiment
648 .iter()
649 .filter(|(_, score)| *score < 0.0)
650 .take(5)
651 .map(|(tweet, _)| tweet.clone())
652 .collect();
653
654 let top_entities = vec![EntityMention {
656 name: token_symbol.clone(),
657 mention_count: tweets_with_sentiment.len() as u32,
658 avg_sentiment: overall_sentiment,
659 }];
660
661 let analysis = SentimentAnalysis {
662 overall_sentiment,
663 sentiment_breakdown,
664 tweet_count: tweets_with_sentiment.len() as u32,
665 analyzed_at: Utc::now(),
666 top_positive_tweets,
667 top_negative_tweets,
668 top_entities,
669 };
670
671 info!(
672 "Sentiment analysis for ${}: {:.2} (from {} tweets)",
673 token_symbol, overall_sentiment, analysis.tweet_count
674 );
675
676 Ok(analysis)
677}
678
679async fn parse_twitter_response(response: &str) -> crate::error::Result<Vec<TwitterPost>> {
682 info!(
683 "Parsing REAL Twitter API v2 response (length: {})",
684 response.len()
685 );
686
687 let api_response: api_types::ApiResponseRaw = serde_json::from_str(response).map_err(|e| {
689 crate::error::WebToolError::Api(format!("Failed to parse Twitter API response: {}", e))
690 })?;
691
692 let mut tweets = Vec::new();
693
694 if let Some(data) = api_response.data {
696 let users = api_response
697 .includes
698 .as_ref()
699 .and_then(|i| i.users.as_ref())
700 .map_or([].as_slice(), |u| u.as_slice());
701
702 for tweet_raw in data {
703 let default_id = String::default();
705 let author_id = tweet_raw.author_id.as_ref().unwrap_or(&default_id);
706 let user_raw = users.iter().find(|u| u.id == *author_id);
707
708 let user = user_raw.cloned().unwrap_or_else(|| api_types::UserRaw {
709 id: author_id.clone(),
710 username: "unknown".to_string(),
711 name: "Unknown User".to_string(),
712 description: None,
713 public_metrics: None,
714 verified: Some(false),
715 created_at: None,
716 });
717
718 let tweet = convert_raw_tweet(&tweet_raw, &user)?;
720 tweets.push(tweet);
721 }
722 }
723
724 if tweets.is_empty() {
725 info!("No tweets found in Twitter API response");
726 } else {
727 info!(
728 "Successfully parsed {} real tweets from Twitter API",
729 tweets.len()
730 );
731 }
732
733 Ok(tweets)
734}
735
736fn convert_raw_tweet(
738 tweet: &api_types::TweetRaw,
739 user: &api_types::UserRaw,
740) -> crate::error::Result<TwitterPost> {
741 let created_at = tweet
743 .created_at
744 .as_ref()
745 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
746 .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc));
747
748 let metrics = if let Some(m) = &tweet.public_metrics {
750 TweetMetrics {
751 retweet_count: m.retweet_count.unwrap_or(0),
752 like_count: m.like_count.unwrap_or(0),
753 reply_count: m.reply_count.unwrap_or(0),
754 quote_count: m.quote_count.unwrap_or(0),
755 impression_count: m.impression_count,
756 }
757 } else {
758 TweetMetrics::default()
759 };
760
761 let entities = if let Some(e) = &tweet.entities {
763 TweetEntities {
764 hashtags: e
765 .hashtags
766 .as_ref()
767 .map_or_else(Vec::new, |h| h.iter().map(|tag| tag.tag.clone()).collect()),
768 mentions: e.mentions.as_ref().map_or_else(Vec::new, |m| {
769 m.iter().map(|mention| mention.username.clone()).collect()
770 }),
771 urls: e.urls.as_ref().map_or_else(Vec::new, |u| {
772 u.iter()
773 .map(|url| url.expanded_url.as_ref().unwrap_or(&url.url).clone())
774 .collect()
775 }),
776 cashtags: e.cashtags.as_ref().map_or_else(Vec::new, |c| {
777 c.iter().map(|cash| cash.tag.clone()).collect()
778 }),
779 }
780 } else {
781 TweetEntities {
782 hashtags: vec![],
783 mentions: vec![],
784 urls: vec![],
785 cashtags: vec![],
786 }
787 };
788
789 let context_annotations =
791 tweet
792 .context_annotations
793 .as_ref()
794 .map_or_else(Vec::new, |annotations| {
795 annotations
796 .iter()
797 .map(|a| ContextAnnotation {
798 domain_id: a.domain.id.clone(),
799 domain_name: a.domain.name.clone().unwrap_or_default(),
800 entity_id: a.entity.id.clone(),
801 entity_name: a.entity.name.clone().unwrap_or_default(),
802 })
803 .collect()
804 });
805
806 let is_reply = tweet
808 .referenced_tweets
809 .as_ref()
810 .is_some_and(|refs| refs.iter().any(|r| r.r#type == "replied_to"));
811
812 let is_retweet = tweet
813 .referenced_tweets
814 .as_ref()
815 .is_some_and(|refs| refs.iter().any(|r| r.r#type == "retweeted"))
816 || tweet.text.starts_with("RT @");
817
818 let author = convert_raw_user(user)?;
820
821 Ok(TwitterPost {
822 id: tweet.id.clone(),
823 text: tweet.text.clone(),
824 author,
825 created_at,
826 metrics,
827 entities,
828 lang: tweet.lang.clone(),
829 is_reply,
830 is_retweet,
831 context_annotations,
832 })
833}
834
835fn convert_raw_user(user: &api_types::UserRaw) -> crate::error::Result<TwitterUser> {
837 let created_at = user
838 .created_at
839 .as_ref()
840 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
841 .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc));
842
843 let metrics = user.public_metrics.as_ref();
844
845 Ok(TwitterUser {
846 id: user.id.clone(),
847 username: user.username.clone(),
848 name: user.name.clone(),
849 description: user.description.clone(),
850 followers_count: metrics.map_or(0, |m| m.followers_count.unwrap_or(0)),
851 following_count: metrics.map_or(0, |m| m.following_count.unwrap_or(0)),
852 tweet_count: metrics.map_or(0, |m| m.tweet_count.unwrap_or(0)),
853 verified: user.verified.unwrap_or(false),
854 created_at,
855 })
856}
857
858async fn analyze_tweet_sentiment(tweets: &[TwitterPost]) -> crate::error::Result<Vec<TwitterPost>> {
860 let mut analyzed_tweets = Vec::new();
864
865 for tweet in tweets {
866 let _sentiment_score = calculate_text_sentiment(&tweet.text);
868
869 let analyzed_tweet = tweet.clone();
871
872 analyzed_tweets.push(analyzed_tweet);
877 }
878
879 Ok(analyzed_tweets)
880}
881
882async fn analyze_tweet_sentiment_scores(tweets: &[TwitterPost]) -> crate::error::Result<Vec<f64>> {
884 let scores: Vec<f64> = tweets
886 .iter()
887 .map(|tweet| {
888 calculate_text_sentiment(&tweet.text)
890 })
891 .collect();
892
893 Ok(scores)
894}
895
896fn calculate_text_sentiment(text: &str) -> f64 {
898 let positive_words = [
900 "bullish",
901 "moon",
902 "pump",
903 "gains",
904 "profit",
905 "growth",
906 "strong",
907 "buy",
908 "accumulate",
909 "breakout",
910 "rally",
911 "surge",
912 "soar",
913 "boom",
914 "amazing",
915 "excellent",
916 "great",
917 "fantastic",
918 "wonderful",
919 "love",
920 "excited",
921 "optimistic",
922 "confident",
923 "winning",
924 "success",
925 "up",
926 "green",
927 "ath",
928 "gem",
929 "rocket",
930 "fire",
931 "diamond",
932 "gold",
933 "hodl",
934 "hold",
935 "long",
936 "support",
937 "resistance",
938 "breakthrough",
939 ];
940
941 let negative_words = [
942 "bearish",
943 "dump",
944 "crash",
945 "loss",
946 "decline",
947 "drop",
948 "weak",
949 "sell",
950 "liquidation",
951 "rekt",
952 "scam",
953 "rug",
954 "fail",
955 "collapse",
956 "terrible",
957 "awful",
958 "bad",
959 "horrible",
960 "hate",
961 "fear",
962 "panic",
963 "worried",
964 "concern",
965 "down",
966 "red",
967 "blood",
968 "bleeding",
969 "pain",
970 "bubble",
971 "ponzi",
972 "fraud",
973 "warning",
974 "danger",
975 "risk",
976 "avoid",
977 "short",
978 "dead",
979 "over",
980 "finished",
981 "broke",
982 "bankruptcy",
983 ];
984
985 let intensifiers = [
987 "very",
988 "extremely",
989 "really",
990 "absolutely",
991 "totally",
992 "completely",
993 ];
994 let negations = ["not", "no", "never", "neither", "nor", "none", "nothing"];
995
996 let text_lower = text.to_lowercase();
998 let words: Vec<&str> = text_lower.split_whitespace().collect();
999
1000 let mut score = 0.0;
1001 let mut word_count = 0;
1002
1003 for (i, word) in words.iter().enumerate() {
1004 let is_negated = i > 0 && negations.contains(&words[i - 1]);
1006
1007 let is_intensified = i > 0 && intensifiers.contains(&words[i - 1]);
1009 let intensity_multiplier = if is_intensified { 1.5 } else { 1.0 };
1010
1011 let mut word_score = 0.0;
1013
1014 if positive_words.iter().any(|&pw| word.contains(pw)) {
1015 word_score = 1.0 * intensity_multiplier;
1016 word_count += 1;
1017 } else if negative_words.iter().any(|&nw| word.contains(nw)) {
1018 word_score = -intensity_multiplier;
1019 word_count += 1;
1020 }
1021
1022 if is_negated {
1024 word_score *= -1.0;
1025 }
1026
1027 score += word_score;
1028 }
1029
1030 let positive_emojis = ["🚀", "💎", "🔥", "💪", "🎯", "✅", "💚", "📈", "🤑", "💰"];
1032 let negative_emojis = ["📉", "💔", "❌", "⚠️", "🔴", "😭", "😱", "💀", "🩸", "📊"];
1033
1034 for emoji in positive_emojis {
1035 if text.contains(emoji) {
1036 score += 0.5;
1037 word_count += 1;
1038 }
1039 }
1040
1041 for emoji in negative_emojis {
1042 if text.contains(emoji) {
1043 score -= 0.5;
1044 word_count += 1;
1045 }
1046 }
1047
1048 if word_count > 0 {
1053 let normalized_score = score / word_count as f64;
1054 normalized_score.clamp(-1.0, 1.0)
1056 } else {
1057 0.0 }
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063 use super::*;
1064 use serde_json::json;
1065
1066 fn create_mock_user() -> TwitterUser {
1068 TwitterUser {
1069 id: "123456".to_string(),
1070 username: "testuser".to_string(),
1071 name: "Test User".to_string(),
1072 description: Some("Test description".to_string()),
1073 followers_count: 1000,
1074 following_count: 500,
1075 tweet_count: 100,
1076 verified: false,
1077 created_at: Utc::now(),
1078 }
1079 }
1080
1081 fn create_mock_post() -> TwitterPost {
1083 TwitterPost {
1084 id: "123".to_string(),
1085 text: "Test tweet".to_string(),
1086 author: create_mock_user(),
1087 created_at: Utc::now(),
1088 metrics: TweetMetrics {
1089 retweet_count: 10,
1090 like_count: 50,
1091 reply_count: 5,
1092 quote_count: 2,
1093 impression_count: Some(1000),
1094 },
1095 entities: TweetEntities {
1096 hashtags: vec!["test".to_string()],
1097 mentions: vec!["@user".to_string()],
1098 urls: vec!["https://example.com".to_string()],
1099 cashtags: vec!["$BTC".to_string()],
1100 },
1101 lang: Some("en".to_string()),
1102 is_reply: false,
1103 is_retweet: false,
1104 context_annotations: vec![],
1105 }
1106 }
1107
1108 #[test]
1110 fn test_twitter_config_new() {
1111 let config = TwitterConfig::new("test_token_123".to_string());
1112
1113 assert_eq!(config.bearer_token, "test_token_123");
1114 assert_eq!(config.base_url, "https://api.twitter.com/2");
1115 assert_eq!(config.max_results, 100);
1116 assert_eq!(config.rate_limit_window, 900);
1117 assert_eq!(config.max_requests_per_window, 300);
1118 }
1119
1120 #[test]
1121 fn test_twitter_config_with_empty_token() {
1122 let config = TwitterConfig::new("".to_string());
1123
1124 assert_eq!(config.bearer_token, "");
1125 assert_eq!(config.base_url, "https://api.twitter.com/2");
1126 assert_eq!(config.max_results, 100);
1127 assert_eq!(config.rate_limit_window, 900);
1128 assert_eq!(config.max_requests_per_window, 300);
1129 }
1130
1131 #[test]
1132 fn test_twitter_config_clone() {
1133 let config1 = TwitterConfig {
1134 bearer_token: "token".to_string(),
1135 base_url: "https://api.test.com".to_string(),
1136 max_results: 50,
1137 rate_limit_window: 600,
1138 max_requests_per_window: 100,
1139 };
1140 let config2 = config1.clone();
1141
1142 assert_eq!(config1.bearer_token, config2.bearer_token);
1143 assert_eq!(config1.base_url, config2.base_url);
1144 assert_eq!(config1.max_results, config2.max_results);
1145 }
1146
1147 #[test]
1149 fn test_twitter_post_serialization() {
1150 let post = create_mock_post();
1151 let json = serde_json::to_string(&post).unwrap();
1152 assert!(json.contains("Test tweet"));
1153 assert!(json.contains("testuser"));
1154
1155 let deserialized: TwitterPost = serde_json::from_str(&json).unwrap();
1156 assert_eq!(deserialized.id, post.id);
1157 assert_eq!(deserialized.text, post.text);
1158 }
1159
1160 #[test]
1161 fn test_twitter_user_serialization() {
1162 let user = create_mock_user();
1163 let json = serde_json::to_string(&user).unwrap();
1164 assert!(json.contains("testuser"));
1165 assert!(json.contains("Test User"));
1166
1167 let deserialized: TwitterUser = serde_json::from_str(&json).unwrap();
1168 assert_eq!(deserialized.username, user.username);
1169 assert_eq!(deserialized.verified, user.verified);
1170 }
1171
1172 #[test]
1173 fn test_tweet_metrics_default() {
1174 let metrics = TweetMetrics::default();
1175 assert_eq!(metrics.retweet_count, 0);
1176 assert_eq!(metrics.like_count, 0);
1177 assert_eq!(metrics.reply_count, 0);
1178 assert_eq!(metrics.quote_count, 0);
1179 assert_eq!(metrics.impression_count, None);
1180 }
1181
1182 #[test]
1183 fn test_tweet_metrics_serialization() {
1184 let metrics = TweetMetrics {
1185 retweet_count: 5,
1186 like_count: 10,
1187 reply_count: 2,
1188 quote_count: 1,
1189 impression_count: Some(500),
1190 };
1191
1192 let json = serde_json::to_string(&metrics).unwrap();
1193 let deserialized: TweetMetrics = serde_json::from_str(&json).unwrap();
1194 assert_eq!(deserialized.like_count, 10);
1195 assert_eq!(deserialized.impression_count, Some(500));
1196 }
1197
1198 #[test]
1199 fn test_tweet_entities_serialization() {
1200 let entities = TweetEntities {
1201 hashtags: vec!["crypto".to_string(), "bitcoin".to_string()],
1202 mentions: vec!["@elonmusk".to_string()],
1203 urls: vec!["https://bitcoin.org".to_string()],
1204 cashtags: vec!["$BTC".to_string(), "$ETH".to_string()],
1205 };
1206
1207 let json = serde_json::to_string(&entities).unwrap();
1208 let deserialized: TweetEntities = serde_json::from_str(&json).unwrap();
1209 assert_eq!(deserialized.hashtags.len(), 2);
1210 assert_eq!(deserialized.cashtags[0], "$BTC");
1211 }
1212
1213 #[test]
1214 fn test_context_annotation_serialization() {
1215 let annotation = ContextAnnotation {
1216 domain_id: "65".to_string(),
1217 domain_name: "Interests and Hobbies Vertical".to_string(),
1218 entity_id: "1142253618110902272".to_string(),
1219 entity_name: "Cryptocurrency".to_string(),
1220 };
1221
1222 let json = serde_json::to_string(&annotation).unwrap();
1223 let deserialized: ContextAnnotation = serde_json::from_str(&json).unwrap();
1224 assert_eq!(deserialized.entity_name, "Cryptocurrency");
1225 }
1226
1227 #[test]
1228 fn test_sentiment_analysis_serialization() {
1229 let analysis = SentimentAnalysis {
1230 overall_sentiment: 0.5,
1231 sentiment_breakdown: SentimentBreakdown {
1232 positive_pct: 60.0,
1233 neutral_pct: 30.0,
1234 negative_pct: 10.0,
1235 positive_avg_engagement: 100.0,
1236 negative_avg_engagement: 50.0,
1237 },
1238 tweet_count: 100,
1239 analyzed_at: Utc::now(),
1240 top_positive_tweets: vec![create_mock_post()],
1241 top_negative_tweets: vec![],
1242 top_entities: vec![EntityMention {
1243 name: "Bitcoin".to_string(),
1244 mention_count: 50,
1245 avg_sentiment: 0.3,
1246 }],
1247 };
1248
1249 let json = serde_json::to_string(&analysis).unwrap();
1250 let deserialized: SentimentAnalysis = serde_json::from_str(&json).unwrap();
1251 assert_eq!(deserialized.tweet_count, 100);
1252 assert_eq!(deserialized.overall_sentiment, 0.5);
1253 }
1254
1255 #[test]
1257 fn test_calculate_text_sentiment_positive_words() {
1258 let text = "This is bullish and amazing! Moon rocket 🚀";
1259 let score = calculate_text_sentiment(text);
1260 assert!(score > 0.0, "Expected positive sentiment, got {}", score);
1261 }
1262
1263 #[test]
1264 fn test_calculate_text_sentiment_negative_words() {
1265 let text = "This is bearish and terrible crash dump 📉";
1266 let score = calculate_text_sentiment(text);
1267 assert!(score < 0.0, "Expected negative sentiment, got {}", score);
1268 }
1269
1270 #[test]
1271 fn test_calculate_text_sentiment_neutral_text() {
1272 let text = "This is just some normal text without sentiment";
1273 let score = calculate_text_sentiment(text);
1274 assert_eq!(score, 0.0, "Expected neutral sentiment, got {}", score);
1275 }
1276
1277 #[test]
1278 fn test_calculate_text_sentiment_empty_text() {
1279 let text = "";
1280 let score = calculate_text_sentiment(text);
1281 assert_eq!(score, 0.0);
1282 }
1283
1284 #[test]
1285 fn test_calculate_text_sentiment_with_negation() {
1286 let text = "not bullish at all";
1287 let score = calculate_text_sentiment(text);
1288 assert!(
1289 score < 0.0,
1290 "Expected negative sentiment due to negation, got {}",
1291 score
1292 );
1293 }
1294
1295 #[test]
1296 fn test_calculate_text_sentiment_with_intensifier() {
1297 let text = "very bullish and extremely amazing";
1298 let score = calculate_text_sentiment(text);
1299 assert!(
1300 score > 0.5,
1301 "Expected high positive sentiment with intensifiers, got {}",
1302 score
1303 );
1304 }
1305
1306 #[test]
1307 fn test_calculate_text_sentiment_positive_emojis() {
1308 let text = "Bitcoin 🚀💎🔥";
1309 let score = calculate_text_sentiment(text);
1310 assert!(score > 0.0);
1311 }
1312
1313 #[test]
1314 fn test_calculate_text_sentiment_negative_emojis() {
1315 let text = "Bitcoin 📉💔❌";
1316 let score = calculate_text_sentiment(text);
1317 assert!(score < 0.0);
1318 }
1319
1320 #[test]
1321 fn test_calculate_text_sentiment_mixed_emotions() {
1322 let text = "bullish but also bearish";
1323 let score = calculate_text_sentiment(text);
1324 assert_eq!(
1325 score, 0.0,
1326 "Expected neutral for mixed sentiment, got {}",
1327 score
1328 );
1329 }
1330
1331 #[test]
1332 fn test_calculate_text_sentiment_case_insensitive() {
1333 let text = "BULLISH AND AMAZING";
1334 let score = calculate_text_sentiment(text);
1335 assert!(score > 0.0);
1336 }
1337
1338 #[test]
1339 fn test_calculate_text_sentiment_clamps_range() {
1340 let text = "extremely very bullish amazing fantastic wonderful excellent great";
1342 let score = calculate_text_sentiment(text);
1343 assert!(
1344 score <= 1.0 && score >= -1.0,
1345 "Score should be in [-1.0, 1.0], got {}",
1346 score
1347 );
1348 }
1349
1350 #[tokio::test]
1352 async fn test_parse_twitter_response_valid_json() {
1353 let json_response = json!({
1354 "data": [
1355 {
1356 "id": "123456789",
1357 "text": "Hello world!",
1358 "author_id": "987654321",
1359 "created_at": "2023-01-01T00:00:00.000Z",
1360 "lang": "en",
1361 "public_metrics": {
1362 "retweet_count": 10,
1363 "like_count": 50,
1364 "reply_count": 5,
1365 "quote_count": 2,
1366 "impression_count": 1000
1367 },
1368 "entities": {
1369 "hashtags": [{"tag": "test"}],
1370 "mentions": [{"username": "user1"}],
1371 "urls": [{"expanded_url": "https://example.com", "url": "https://example.com"}],
1372 "cashtags": [{"tag": "BTC"}]
1373 },
1374 "context_annotations": [
1375 {
1376 "domain": {"id": "65", "name": "Interests"},
1377 "entity": {"id": "123", "name": "Crypto"}
1378 }
1379 ]
1380 }
1381 ],
1382 "includes": {
1383 "users": [
1384 {
1385 "id": "987654321",
1386 "username": "testuser",
1387 "name": "Test User",
1388 "description": "Test bio",
1389 "verified": false,
1390 "created_at": "2020-01-01T00:00:00.000Z",
1391 "public_metrics": {
1392 "followers_count": 1000,
1393 "following_count": 500,
1394 "tweet_count": 100,
1395 "listed_count": 10
1396 }
1397 }
1398 ]
1399 }
1400 });
1401
1402 let response_str = json_response.to_string();
1403 let result = parse_twitter_response(&response_str).await;
1404
1405 assert!(result.is_ok());
1406 let tweets = result.unwrap();
1407 assert_eq!(tweets.len(), 1);
1408 assert_eq!(tweets[0].id, "123456789");
1409 assert_eq!(tweets[0].text, "Hello world!");
1410 assert_eq!(tweets[0].author.username, "testuser");
1411 }
1412
1413 #[tokio::test]
1414 async fn test_parse_twitter_response_empty_data() {
1415 let json_response = json!({
1416 "data": [],
1417 "includes": {
1418 "users": []
1419 }
1420 });
1421
1422 let response_str = json_response.to_string();
1423 let result = parse_twitter_response(&response_str).await;
1424
1425 assert!(result.is_ok());
1426 let tweets = result.unwrap();
1427 assert_eq!(tweets.len(), 0);
1428 }
1429
1430 #[tokio::test]
1431 async fn test_parse_twitter_response_no_data_field() {
1432 let json_response = json!({
1433 "includes": {
1434 "users": []
1435 }
1436 });
1437
1438 let response_str = json_response.to_string();
1439 let result = parse_twitter_response(&response_str).await;
1440
1441 assert!(result.is_ok());
1442 let tweets = result.unwrap();
1443 assert_eq!(tweets.len(), 0);
1444 }
1445
1446 #[tokio::test]
1447 async fn test_parse_twitter_response_invalid_json() {
1448 let invalid_json = "invalid json";
1449 let result = parse_twitter_response(invalid_json).await;
1450
1451 assert!(result.is_err());
1452 assert!(result
1453 .unwrap_err()
1454 .to_string()
1455 .contains("Failed to parse Twitter API response"));
1456 }
1457
1458 #[tokio::test]
1459 async fn test_parse_twitter_response_no_includes() {
1460 let json_response = json!({
1461 "data": [
1462 {
1463 "id": "123456789",
1464 "text": "Hello world!",
1465 "author_id": "987654321",
1466 "created_at": "2023-01-01T00:00:00.000Z",
1467 "lang": "en",
1468 "public_metrics": null,
1469 "entities": null,
1470 "context_annotations": null,
1471 "referenced_tweets": null
1472 }
1473 ]
1474 });
1475
1476 let response_str = json_response.to_string();
1477 let result = parse_twitter_response(&response_str).await;
1478
1479 assert!(result.is_ok());
1480 let tweets = result.unwrap();
1481 assert_eq!(tweets.len(), 1);
1482 assert_eq!(tweets[0].author.username, "unknown");
1484 assert_eq!(tweets[0].author.name, "Unknown User");
1485 }
1486
1487 #[test]
1489 fn test_convert_raw_tweet_complete_data() {
1490 let tweet_raw = api_types::TweetRaw {
1492 id: "123456789".to_string(),
1493 text: "Hello world!".to_string(),
1494 author_id: Some("987654321".to_string()),
1495 created_at: Some("2023-01-01T00:00:00.000Z".to_string()),
1496 lang: Some("en".to_string()),
1497 entities: Some(api_types::EntitiesRaw {
1498 hashtags: Some(vec![api_types::HashtagRaw {
1499 tag: "test".to_string(),
1500 }]),
1501 mentions: None,
1502 urls: None,
1503 cashtags: None,
1504 }),
1505 public_metrics: Some(api_types::PublicMetricsRaw {
1506 retweet_count: Some(10),
1507 reply_count: Some(5),
1508 like_count: Some(50),
1509 quote_count: Some(2),
1510 impression_count: None,
1511 }),
1512 context_annotations: None,
1513 referenced_tweets: None,
1514 };
1515
1516 let user_raw = api_types::UserRaw {
1517 id: "987654321".to_string(),
1518 username: "testuser".to_string(),
1519 name: "Test User".to_string(),
1520 description: Some("Test bio".to_string()),
1521 verified: Some(false),
1522 created_at: Some("2020-01-01T00:00:00.000Z".to_string()),
1523 public_metrics: None,
1524 };
1525
1526 let tweet = convert_raw_tweet(&tweet_raw, &user_raw).unwrap();
1527 assert_eq!(tweet.id, "123456789");
1528 assert_eq!(tweet.text, "Hello world!");
1529 assert_eq!(tweet.author.username, "testuser");
1530 assert!(!tweet.is_retweet);
1531 assert_eq!(tweet.entities.hashtags.len(), 1);
1532 }
1533
1534 #[test]
1535 fn test_convert_raw_tweet_minimal_data() {
1536 let tweet_raw = api_types::TweetRaw {
1537 id: "123".to_string(),
1538 text: "Minimal tweet".to_string(),
1539 author_id: Some("456".to_string()),
1540 created_at: None,
1541 lang: None,
1542 entities: None,
1543 public_metrics: None,
1544 context_annotations: None,
1545 referenced_tweets: None,
1546 };
1547
1548 let user_raw = api_types::UserRaw {
1550 id: "unknown".to_string(),
1551 username: "unknown".to_string(),
1552 name: "Unknown User".to_string(),
1553 description: None,
1554 verified: None,
1555 created_at: None,
1556 public_metrics: None,
1557 };
1558
1559 let tweet = convert_raw_tweet(&tweet_raw, &user_raw).unwrap();
1560 assert_eq!(tweet.id, "123");
1561 assert_eq!(tweet.text, "Minimal tweet");
1562 assert_eq!(tweet.author.username, "unknown");
1563 assert!(!tweet.is_retweet);
1564 }
1565
1566 #[test]
1567 fn test_convert_raw_tweet_retweet_detection() {
1568 let tweet_raw = api_types::TweetRaw {
1569 id: "123".to_string(),
1570 text: "RT @someone: Original tweet".to_string(),
1571 author_id: Some("456".to_string()),
1572 created_at: None,
1573 lang: None,
1574 entities: None,
1575 public_metrics: None,
1576 context_annotations: None,
1577 referenced_tweets: None,
1578 };
1579
1580 let user_raw = api_types::UserRaw {
1581 id: "456".to_string(),
1582 username: "test".to_string(),
1583 name: "Test".to_string(),
1584 description: None,
1585 created_at: None,
1586 verified: None,
1587 public_metrics: None,
1588 };
1589
1590 let tweet = convert_raw_tweet(&tweet_raw, &user_raw).unwrap();
1591 assert!(tweet.is_retweet);
1592 }
1593
1594 #[test]
1595 fn test_convert_raw_tweet_invalid_date() {
1596 let tweet_raw = api_types::TweetRaw {
1597 id: "123".to_string(),
1598 text: "Test tweet".to_string(),
1599 author_id: Some("456".to_string()),
1600 created_at: Some("invalid-date".to_string()),
1601 lang: None,
1602 entities: None,
1603 public_metrics: None,
1604 context_annotations: None,
1605 referenced_tweets: None,
1606 };
1607
1608 let user_raw = api_types::UserRaw {
1609 id: "456".to_string(),
1610 username: "test".to_string(),
1611 name: "Test".to_string(),
1612 description: None,
1613 created_at: None,
1614 verified: None,
1615 public_metrics: None,
1616 };
1617
1618 let tweet = convert_raw_tweet(&tweet_raw, &user_raw).unwrap();
1619 assert!(tweet.created_at <= Utc::now());
1621 }
1622
1623 #[test]
1624 fn test_convert_raw_tweet_missing_fields() {
1625 let tweet_raw = api_types::TweetRaw {
1626 id: String::default(),
1627 text: String::default(),
1628 author_id: None,
1629 created_at: None,
1630 lang: None,
1631 entities: None,
1632 public_metrics: None,
1633 context_annotations: None,
1634 referenced_tweets: None,
1635 };
1636
1637 let user_raw = api_types::UserRaw {
1638 id: String::default(),
1639 username: String::default(),
1640 name: String::default(),
1641 description: None,
1642 created_at: None,
1643 verified: None,
1644 public_metrics: None,
1645 };
1646
1647 let tweet = convert_raw_tweet(&tweet_raw, &user_raw).unwrap();
1648 assert_eq!(tweet.id, "");
1649 assert_eq!(tweet.text, "");
1650 assert_eq!(tweet.author.id, "");
1651 }
1652
1653 #[test]
1655 fn test_convert_raw_user_complete() {
1656 let user_raw = api_types::UserRaw {
1657 id: "456".to_string(),
1658 username: "user2".to_string(),
1659 name: "User Two".to_string(),
1660 description: Some("Test user bio".to_string()),
1661 created_at: Some("2020-01-01T00:00:00.000Z".to_string()),
1662 verified: Some(true),
1663 public_metrics: Some(api_types::UserMetricsRaw {
1664 followers_count: Some(1000),
1665 following_count: Some(500),
1666 tweet_count: Some(100),
1667 listed_count: Some(10),
1668 }),
1669 };
1670
1671 let user = convert_raw_user(&user_raw).unwrap();
1672 assert_eq!(user.id, "456");
1673 assert_eq!(user.username, "user2");
1674 assert_eq!(user.name, "User Two");
1675 assert!(user.verified);
1676 assert_eq!(user.followers_count, 1000);
1677 assert_eq!(user.following_count, 500);
1678 }
1679
1680 #[test]
1681 fn test_convert_raw_user_minimal() {
1682 let user_raw = api_types::UserRaw {
1683 id: "999".to_string(),
1684 username: "user1".to_string(),
1685 name: "User One".to_string(),
1686 description: None,
1687 created_at: None,
1688 verified: None,
1689 public_metrics: None,
1690 };
1691
1692 let user = convert_raw_user(&user_raw).unwrap();
1693 assert_eq!(user.id, "999");
1694 assert_eq!(user.username, "user1");
1695 assert_eq!(user.name, "User One");
1696 assert!(!user.verified); assert_eq!(user.followers_count, 0); }
1699
1700 #[test]
1701 fn test_convert_raw_user_empty_fields() {
1702 let user_raw = api_types::UserRaw {
1703 id: String::default(),
1704 username: String::default(),
1705 name: String::default(),
1706 description: None,
1707 created_at: None,
1708 verified: None,
1709 public_metrics: None,
1710 };
1711
1712 let user = convert_raw_user(&user_raw).unwrap();
1713 assert_eq!(user.id, "");
1714 assert_eq!(user.username, "");
1715 assert_eq!(user.name, "");
1716 assert!(!user.verified);
1717 }
1718
1719 #[test]
1721 fn test_convert_raw_user_with_metrics() {
1722 let user_raw = api_types::UserRaw {
1723 id: "123456789".to_string(),
1724 username: "testuser".to_string(),
1725 name: "Test User".to_string(),
1726 description: None,
1727 created_at: None,
1728 verified: Some(true),
1729 public_metrics: Some(api_types::UserMetricsRaw {
1730 followers_count: Some(1000),
1731 following_count: Some(500),
1732 tweet_count: Some(50),
1733 listed_count: Some(5),
1734 }),
1735 };
1736
1737 let user = convert_raw_user(&user_raw).unwrap();
1738 assert_eq!(user.id, "123456789");
1739 assert_eq!(user.username, "testuser");
1740 assert_eq!(user.name, "Test User");
1741 assert!(user.verified);
1742 assert_eq!(user.followers_count, 1000);
1743 assert_eq!(user.following_count, 500);
1744 }
1745
1746 #[test]
1748 fn test_convert_raw_tweet_with_entities() {
1749 let entities_raw = api_types::EntitiesRaw {
1750 hashtags: Some(vec![
1751 api_types::HashtagRaw {
1752 tag: "crypto".to_string(),
1753 },
1754 api_types::HashtagRaw {
1755 tag: "bitcoin".to_string(),
1756 },
1757 ]),
1758 mentions: Some(vec![
1759 api_types::MentionRaw {
1760 username: "elonmusk".to_string(),
1761 },
1762 api_types::MentionRaw {
1763 username: "satoshi".to_string(),
1764 },
1765 ]),
1766 urls: Some(vec![
1767 api_types::UrlRaw {
1768 expanded_url: Some("https://bitcoin.org".to_string()),
1769 url: "https://bitcoin.org".to_string(),
1770 },
1771 api_types::UrlRaw {
1772 expanded_url: Some("https://ethereum.org".to_string()),
1773 url: "https://ethereum.org".to_string(),
1774 },
1775 ]),
1776 cashtags: None,
1777 };
1778
1779 let tweet_raw = api_types::TweetRaw {
1780 id: "test".to_string(),
1781 text: "test".to_string(),
1782 author_id: Some("test".to_string()),
1783 created_at: None,
1784 lang: None,
1785 entities: Some(entities_raw),
1786 public_metrics: None,
1787 context_annotations: None,
1788 referenced_tweets: None,
1789 };
1790
1791 let user_raw = api_types::UserRaw {
1792 id: "test".to_string(),
1793 username: "test".to_string(),
1794 name: "Test".to_string(),
1795 description: None,
1796 created_at: None,
1797 verified: None,
1798 public_metrics: None,
1799 };
1800
1801 let tweet = convert_raw_tweet(&tweet_raw, &user_raw).unwrap();
1802 assert_eq!(tweet.entities.hashtags, vec!["crypto", "bitcoin"]);
1803 assert_eq!(tweet.entities.mentions, vec!["elonmusk", "satoshi"]);
1804 assert_eq!(
1805 tweet.entities.urls,
1806 vec!["https://bitcoin.org", "https://ethereum.org"]
1807 );
1808 }
1809
1810 #[test]
1811 fn test_convert_raw_tweet_empty_entities() {
1812 let tweet_raw = api_types::TweetRaw {
1813 id: "test".to_string(),
1814 text: "test".to_string(),
1815 author_id: Some("test".to_string()),
1816 created_at: None,
1817 lang: None,
1818 entities: None,
1819 public_metrics: None,
1820 context_annotations: None,
1821 referenced_tweets: None,
1822 };
1823
1824 let user_raw = api_types::UserRaw {
1825 id: "test".to_string(),
1826 username: "test".to_string(),
1827 name: "Test".to_string(),
1828 description: None,
1829 created_at: None,
1830 verified: None,
1831 public_metrics: None,
1832 };
1833
1834 let tweet = convert_raw_tweet(&tweet_raw, &user_raw).unwrap();
1835 assert!(tweet.entities.hashtags.is_empty());
1836 assert!(tweet.entities.mentions.is_empty());
1837 assert!(tweet.entities.urls.is_empty());
1838 }
1839
1840 #[test]
1841 fn test_convert_raw_tweet_partial_entities() {
1842 let entities_raw = api_types::EntitiesRaw {
1843 hashtags: Some(vec![api_types::HashtagRaw {
1844 tag: "test".to_string(),
1845 }]),
1846 mentions: None,
1847 urls: Some(vec![api_types::UrlRaw {
1848 expanded_url: Some("https://example.com".to_string()),
1849 url: "https://example.com".to_string(),
1850 }]),
1851 cashtags: None,
1852 };
1853
1854 let tweet_raw = api_types::TweetRaw {
1855 id: "test".to_string(),
1856 text: "test".to_string(),
1857 author_id: Some("test".to_string()),
1858 created_at: None,
1859 lang: None,
1860 entities: Some(entities_raw),
1861 public_metrics: None,
1862 context_annotations: None,
1863 referenced_tweets: None,
1864 };
1865
1866 let user_raw = api_types::UserRaw {
1867 id: "test".to_string(),
1868 username: "test".to_string(),
1869 name: "Test".to_string(),
1870 description: None,
1871 created_at: None,
1872 verified: None,
1873 public_metrics: None,
1874 };
1875
1876 let tweet = convert_raw_tweet(&tweet_raw, &user_raw).unwrap();
1877 assert_eq!(tweet.entities.hashtags, vec!["test"]);
1878 assert!(tweet.entities.mentions.is_empty());
1879 assert_eq!(tweet.entities.urls, vec!["https://example.com"]);
1880 }
1881
1882 #[tokio::test]
1884 async fn test_analyze_tweet_sentiment_scores() {
1885 let tweets = vec![
1886 TwitterPost {
1887 text: "Bitcoin is amazing and bullish! 🚀".to_string(),
1888 ..create_mock_post()
1889 },
1890 TwitterPost {
1891 text: "Crypto crash is terrible 📉".to_string(),
1892 ..create_mock_post()
1893 },
1894 TwitterPost {
1895 text: "Neutral statement about blockchain".to_string(),
1896 ..create_mock_post()
1897 },
1898 ];
1899
1900 let result = analyze_tweet_sentiment_scores(&tweets).await;
1901 assert!(result.is_ok());
1902
1903 let scores = result.unwrap();
1904 assert_eq!(scores.len(), 3);
1905 assert!(scores[0] > 0.0); assert!(scores[1] < 0.0); assert_eq!(scores[2], 0.0); }
1909
1910 #[tokio::test]
1911 async fn test_analyze_tweet_sentiment_scores_empty() {
1912 let tweets = vec![];
1913 let result = analyze_tweet_sentiment_scores(&tweets).await;
1914 assert!(result.is_ok());
1915
1916 let scores = result.unwrap();
1917 assert!(scores.is_empty());
1918 }
1919
1920 #[tokio::test]
1922 async fn test_analyze_tweet_sentiment() {
1923 let tweets = vec![create_mock_post()];
1924 let result = analyze_tweet_sentiment(&tweets).await;
1925 assert!(result.is_ok());
1926
1927 let analyzed = result.unwrap();
1928 assert_eq!(analyzed.len(), 1);
1929 assert_eq!(analyzed[0].id, tweets[0].id);
1930 }
1931
1932 #[tokio::test]
1933 async fn test_analyze_tweet_sentiment_empty() {
1934 let tweets = vec![];
1935 let result = analyze_tweet_sentiment(&tweets).await;
1936 assert!(result.is_ok());
1937
1938 let analyzed = result.unwrap();
1939 assert!(analyzed.is_empty());
1940 }
1941
1942 #[test]
1944 fn test_calculate_text_sentiment_crypto_specific_words() {
1945 assert!(calculate_text_sentiment("hodl diamond hands") > 0.0);
1946 assert!(calculate_text_sentiment("rekt liquidation scam") < 0.0);
1947 assert!(calculate_text_sentiment("ath breakout surge") > 0.0);
1948 assert!(calculate_text_sentiment("rug pull ponzi") < 0.0);
1949 }
1950
1951 #[test]
1952 fn test_calculate_text_sentiment_multiple_negations() {
1953 let text = "not not bullish"; let score = calculate_text_sentiment(text);
1955 assert!(score != 0.0);
1958 }
1959
1960 #[test]
1961 fn test_calculate_text_sentiment_partial_word_matching() {
1962 assert!(calculate_text_sentiment("superbullish") > 0.0);
1964 assert!(calculate_text_sentiment("megabearish") < 0.0);
1965 }
1966
1967 #[test]
1968 fn test_calculate_text_sentiment_special_characters() {
1969 let text = "Bitcoin!!! Amazing... Really??? Great!!!";
1970 let score = calculate_text_sentiment(text);
1971 assert!(score > 0.0);
1972 }
1973
1974 #[test]
1975 fn test_calculate_text_sentiment_numbers_and_symbols() {
1976 let text = "$BTC +15% gains! #bullish 2023";
1977 let score = calculate_text_sentiment(text);
1978 assert!(score > 0.0);
1979 }
1980
1981 #[test]
1983 fn test_twitter_user_all_fields() {
1984 let user = TwitterUser {
1985 id: "test_id".to_string(),
1986 username: "test_username".to_string(),
1987 name: "Test Name".to_string(),
1988 description: Some("Bio".to_string()),
1989 followers_count: u32::MAX,
1990 following_count: 0,
1991 tweet_count: 42,
1992 verified: true,
1993 created_at: Utc::now(),
1994 };
1995
1996 assert_eq!(user.followers_count, u32::MAX);
1997 assert_eq!(user.following_count, 0);
1998 assert!(user.verified);
1999 assert!(user.description.is_some());
2000 }
2001
2002 #[test]
2003 fn test_rate_limit_info_all_fields() {
2004 let rate_limit = RateLimitInfo {
2005 remaining: 299,
2006 limit: 300,
2007 reset_at: 1234567890,
2008 };
2009
2010 assert_eq!(rate_limit.remaining, 299);
2011 assert_eq!(rate_limit.limit, 300);
2012 assert_eq!(rate_limit.reset_at, 1234567890);
2013 }
2014
2015 #[test]
2016 fn test_search_metadata_all_fields() {
2017 let metadata = SearchMetadata {
2018 result_count: 42,
2019 query: "test query".to_string(),
2020 next_token: Some("next_123".to_string()),
2021 searched_at: Utc::now(),
2022 };
2023
2024 assert_eq!(metadata.result_count, 42);
2025 assert!(metadata.next_token.is_some());
2026 assert_eq!(metadata.next_token.unwrap(), "next_123");
2027 }
2028
2029 #[test]
2030 fn test_entity_mention_all_fields() {
2031 let entity = EntityMention {
2032 name: "Bitcoin".to_string(),
2033 mention_count: 1000,
2034 avg_sentiment: -0.5,
2035 };
2036
2037 assert_eq!(entity.mention_count, 1000);
2038 assert_eq!(entity.avg_sentiment, -0.5);
2039 }
2040
2041 #[test]
2042 fn test_sentiment_breakdown_all_fields() {
2043 let breakdown = SentimentBreakdown {
2044 positive_pct: 33.3,
2045 neutral_pct: 33.3,
2046 negative_pct: 33.4,
2047 positive_avg_engagement: 150.0,
2048 negative_avg_engagement: 75.0,
2049 };
2050
2051 assert_eq!(breakdown.positive_pct, 33.3);
2052 assert_eq!(breakdown.negative_pct, 33.4);
2053 assert_eq!(breakdown.positive_avg_engagement, 150.0);
2054 }
2055
2056 #[test]
2058 fn test_twitter_bearer_token_constant() {
2059 }
2062
2063 #[test]
2065 fn test_calculate_text_sentiment_only_intensifiers() {
2066 let text = "very extremely really absolutely";
2067 let score = calculate_text_sentiment(text);
2068 assert_eq!(score, 0.0); }
2070
2071 #[test]
2072 fn test_calculate_text_sentiment_only_negations() {
2073 let text = "not no never neither";
2074 let score = calculate_text_sentiment(text);
2075 assert_eq!(score, 0.0); }
2077
2078 #[test]
2079 fn test_calculate_text_sentiment_single_word() {
2080 assert!(calculate_text_sentiment("bullish") > 0.0);
2081 assert!(calculate_text_sentiment("bearish") < 0.0);
2082 assert_eq!(calculate_text_sentiment("random"), 0.0);
2083 }
2084
2085 #[tokio::test]
2087 async fn test_parse_twitter_response_malformed_data() {
2088 let json_response = json!({
2089 "data": [
2090 "not_an_object",
2091 {"id": "valid_tweet", "text": "valid", "author_id": "123"}
2092 ],
2093 "includes": {"users": []}
2094 });
2095
2096 let response_str = json_response.to_string();
2097 let result = parse_twitter_response(&response_str).await;
2098
2099 assert!(result.is_err());
2101 }
2102}