1use crate::{client::WebClient, error::WebToolError};
8use chrono::{DateTime, Utc};
9use riglr_core::provider::ApplicationContext;
10use riglr_macros::tool;
11use schemars::JsonSchema;
12
13const LUNARCRUSH_API_KEY: &str = "LUNARCRUSH_API_KEY";
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use tracing::{debug, info};
17
18#[derive(Debug, Clone)]
20pub struct LunarCrushConfig {
21 pub api_key: String,
23 pub base_url: String,
25 pub rate_limit_per_minute: u32,
27}
28
29impl Default for LunarCrushConfig {
30 fn default() -> Self {
31 Self {
32 api_key: std::env::var(LUNARCRUSH_API_KEY).unwrap_or_default(),
33 base_url: "https://api.lunarcrush.com/v2".to_string(),
34 rate_limit_per_minute: 60,
35 }
36 }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
41pub struct SentimentData {
42 pub symbol: String,
44 pub social_score: f64,
46 pub sentiment_score: f64,
48 pub social_volume: u64,
50 pub mentions: u64,
52 pub influencer_posts: u64,
54 pub avg_social_volume: f64,
56 pub social_volume_change: f64,
58 pub platform_sentiment: HashMap<String, f64>,
60 pub trending_keywords: Vec<String>,
62 pub timestamp: DateTime<Utc>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
68pub struct TrendingCrypto {
69 pub symbol: String,
71 pub name: String,
73 pub market_rank: u32,
75 pub galaxy_score: f64,
77 pub alt_rank: u32,
79 pub social_score: f64,
81 pub price_usd: f64,
83 pub price_change_24h: f64,
85 pub social_volume: u64,
87 pub social_volume_change: f64,
89 pub market_cap: u64,
91 pub volume_24h: u64,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
97pub struct InfluencerMention {
98 pub id: String,
100 pub influencer_username: String,
102 pub influencer_name: String,
104 pub followers: u64,
106 pub text: String,
108 pub timestamp: DateTime<Utc>,
110 pub platform: String,
112 pub likes: u64,
114 pub shares: u64,
116 pub comments: u64,
118 pub url: Option<String>,
120 pub sentiment: f64,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
126pub struct InfluencerMentionsResult {
127 pub symbol: String,
129 pub mentions: Vec<InfluencerMention>,
131 pub total_mentions: u64,
133 pub timeframe: String,
135 pub avg_sentiment: f64,
137}
138
139fn get_api_key_from_context(context: &ApplicationContext) -> Result<String, WebToolError> {
141 context
142 .config
143 .providers
144 .lunarcrush_api_key
145 .clone()
146 .ok_or_else(|| {
147 WebToolError::Config(
148 "LunarCrush API key not configured. Set LUNARCRUSH_API_KEY in your environment."
149 .to_string(),
150 )
151 })
152}
153
154async fn create_lunarcrush_client_with_context(
156 context: &ApplicationContext,
157) -> Result<WebClient, WebToolError> {
158 let api_key = get_api_key_from_context(context)?;
159 let client = WebClient::default().with_api_key("lunarcrush", api_key);
160 Ok(client)
161}
162
163#[allow(dead_code)]
165async fn create_lunarcrush_client() -> Result<WebClient, WebToolError> {
166 let config = LunarCrushConfig::default();
167
168 if config.api_key.is_empty() {
169 return Err(WebToolError::Config(
170 "LUNARCRUSH_API_KEY environment variable not set".to_string(),
171 ));
172 }
173
174 let client = WebClient::default().with_api_key("lunarcrush", config.api_key);
175
176 Ok(client)
177}
178
179#[tool]
180pub async fn get_social_sentiment(
192 context: &ApplicationContext,
193 symbol: String,
194 timeframe: Option<String>,
195) -> Result<SentimentData, WebToolError> {
196 info!(
197 "Fetching social sentiment for {} (timeframe: {})",
198 symbol,
199 timeframe.as_deref().unwrap_or("24h")
200 );
201
202 let client = create_lunarcrush_client_with_context(context).await?;
203 let timeframe = timeframe.unwrap_or_else(|| "24h".to_string());
204
205 if !["1h", "24h", "7d", "30d"].contains(&timeframe.as_str()) {
207 return Err(WebToolError::Config(
208 "Invalid timeframe. Must be one of: 1h, 24h, 7d, 30d".to_string(),
209 ));
210 }
211
212 let mut params = HashMap::new();
213 params.insert("symbol".to_string(), symbol.to_uppercase());
214 params.insert("data_points".to_string(), "1".to_string());
215 params.insert("interval".to_string(), timeframe.clone());
216
217 let config = LunarCrushConfig::default();
218 let url = format!("{}/assets", config.base_url);
219
220 let response_text = client.get_with_params(&url, ¶ms).await?;
221
222 let response_data: serde_json::Value = serde_json::from_str(&response_text)
223 .map_err(|e| WebToolError::Parsing(format!("Invalid JSON response: {}", e)))?;
224
225 let data = response_data
227 .get("data")
228 .ok_or_else(|| WebToolError::Parsing("Missing 'data' field".to_string()))?;
229
230 let assets = data
231 .as_array()
232 .ok_or_else(|| WebToolError::Parsing("'data' is not an array".to_string()))?;
233
234 let asset = assets
235 .first()
236 .ok_or_else(|| WebToolError::Parsing(format!("No data found for symbol {}", symbol)))?;
237
238 let social_score = asset
240 .get("galaxy_score")
241 .and_then(|v| v.as_f64())
242 .unwrap_or(0.0);
243 let sentiment_score = asset
244 .get("sentiment")
245 .and_then(|v| v.as_f64())
246 .unwrap_or(0.0);
247 let social_volume = asset
248 .get("social_volume")
249 .and_then(|v| v.as_u64())
250 .unwrap_or(0);
251 let mentions = asset
252 .get("total_mentions")
253 .and_then(|v| v.as_u64())
254 .unwrap_or(0);
255 let influencer_posts = asset
256 .get("influencer_posts")
257 .and_then(|v| v.as_u64())
258 .unwrap_or(0);
259 let avg_social_volume = asset
260 .get("avg_social_volume")
261 .and_then(|v| v.as_f64())
262 .unwrap_or(0.0);
263 let social_volume_change = asset
264 .get("social_volume_change_24h")
265 .and_then(|v| v.as_f64())
266 .unwrap_or(0.0);
267
268 let mut platform_sentiment = HashMap::new();
270 if let Some(platforms) = asset.get("platform_sentiment") {
271 if let Some(twitter_sentiment) = platforms.get("twitter").and_then(|v| v.as_f64()) {
272 platform_sentiment.insert("twitter".to_string(), twitter_sentiment);
273 }
274 if let Some(reddit_sentiment) = platforms.get("reddit").and_then(|v| v.as_f64()) {
275 platform_sentiment.insert("reddit".to_string(), reddit_sentiment);
276 }
277 }
278
279 let trending_keywords = asset
281 .get("keywords")
282 .and_then(|v| v.as_array())
283 .map(|arr| {
284 arr.iter()
285 .filter_map(|kw| kw.as_str().map(|s| s.to_string()))
286 .collect()
287 })
288 .unwrap_or_default();
289
290 debug!(
291 "Successfully fetched sentiment data for {} with {} mentions",
292 symbol, mentions
293 );
294
295 Ok(SentimentData {
296 symbol: symbol.to_uppercase(),
297 social_score,
298 sentiment_score,
299 social_volume,
300 mentions,
301 influencer_posts,
302 avg_social_volume,
303 social_volume_change,
304 platform_sentiment,
305 trending_keywords,
306 timestamp: Utc::now(),
307 })
308}
309
310#[tool]
311pub async fn get_trending_cryptos(
323 context: &ApplicationContext,
324 limit: Option<u32>,
325 sort_by: Option<String>,
326) -> Result<Vec<TrendingCrypto>, WebToolError> {
327 let limit = limit.unwrap_or(10).clamp(1, 50);
328 let sort_by = sort_by.unwrap_or_else(|| "galaxy_score".to_string());
329
330 info!(
331 "Fetching top {} trending cryptos sorted by {}",
332 limit, sort_by
333 );
334
335 if !["galaxy_score", "social_volume", "alt_rank"].contains(&sort_by.as_str()) {
337 return Err(WebToolError::Config(
338 "Invalid sort_by parameter. Must be one of: galaxy_score, social_volume, alt_rank"
339 .to_string(),
340 ));
341 }
342
343 let client = create_lunarcrush_client_with_context(context).await?;
344
345 let mut params = HashMap::new();
346 params.insert("limit".to_string(), limit.to_string());
347 params.insert("sort".to_string(), sort_by);
348
349 let config = LunarCrushConfig::default();
350 let url = format!("{}/market", config.base_url);
351
352 let response_text = client.get_with_params(&url, ¶ms).await?;
353
354 let response_data: serde_json::Value = serde_json::from_str(&response_text)
355 .map_err(|e| WebToolError::Parsing(format!("Invalid JSON response: {}", e)))?;
356
357 let data = response_data
358 .get("data")
359 .ok_or_else(|| WebToolError::Parsing("Missing 'data' field".to_string()))?;
360
361 let assets = data
362 .as_array()
363 .ok_or_else(|| WebToolError::Parsing("'data' is not an array".to_string()))?;
364
365 let mut trending_cryptos = Vec::new();
366
367 for asset in assets.iter().take(limit as usize) {
368 let symbol = asset
369 .get("symbol")
370 .and_then(|v| v.as_str())
371 .unwrap_or("UNKNOWN")
372 .to_string();
373
374 let name = asset
375 .get("name")
376 .and_then(|v| v.as_str())
377 .unwrap_or("Unknown")
378 .to_string();
379
380 let market_rank = asset
381 .get("market_cap_rank")
382 .and_then(|v| v.as_u64())
383 .unwrap_or(0) as u32;
384 let galaxy_score = asset
385 .get("galaxy_score")
386 .and_then(|v| v.as_f64())
387 .unwrap_or(0.0);
388 let alt_rank = asset.get("alt_rank").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
389 let social_score = asset
390 .get("social_score")
391 .and_then(|v| v.as_f64())
392 .unwrap_or(0.0);
393 let price_usd = asset.get("price").and_then(|v| v.as_f64()).unwrap_or(0.0);
394 let price_change_24h = asset
395 .get("percent_change_24h")
396 .and_then(|v| v.as_f64())
397 .unwrap_or(0.0);
398 let social_volume = asset
399 .get("social_volume")
400 .and_then(|v| v.as_u64())
401 .unwrap_or(0);
402 let social_volume_change = asset
403 .get("social_volume_change_24h")
404 .and_then(|v| v.as_f64())
405 .unwrap_or(0.0);
406 let market_cap = asset
407 .get("market_cap")
408 .and_then(|v| v.as_u64())
409 .unwrap_or(0);
410 let volume_24h = asset
411 .get("volume_24h")
412 .and_then(|v| v.as_u64())
413 .unwrap_or(0);
414
415 trending_cryptos.push(TrendingCrypto {
416 symbol,
417 name,
418 market_rank,
419 galaxy_score,
420 alt_rank,
421 social_score,
422 price_usd,
423 price_change_24h,
424 social_volume,
425 social_volume_change,
426 market_cap,
427 volume_24h,
428 });
429 }
430
431 debug!(
432 "Successfully fetched {} trending cryptocurrencies",
433 trending_cryptos.len()
434 );
435
436 Ok(trending_cryptos)
437}
438
439#[tool]
440pub async fn get_influencer_mentions(
453 context: &ApplicationContext,
454 token_symbol: String,
455 limit: Option<u32>,
456 timeframe: Option<String>,
457) -> Result<InfluencerMentionsResult, WebToolError> {
458 let limit = limit.unwrap_or(20).clamp(1, 50);
459 let timeframe = timeframe.unwrap_or_else(|| "24h".to_string());
460
461 info!(
462 "Fetching influencer mentions for {} (limit: {}, timeframe: {})",
463 token_symbol, limit, timeframe
464 );
465
466 if !["24h", "7d", "30d"].contains(&timeframe.as_str()) {
468 return Err(WebToolError::Config(
469 "Invalid timeframe. Must be one of: 24h, 7d, 30d".to_string(),
470 ));
471 }
472
473 let client = create_lunarcrush_client_with_context(context).await?;
474
475 let mut params = HashMap::new();
476 params.insert("symbol".to_string(), token_symbol.to_uppercase());
477 params.insert("limit".to_string(), limit.to_string());
478 params.insert("interval".to_string(), timeframe.clone());
479 params.insert("sources".to_string(), "influencers".to_string());
480
481 let config = LunarCrushConfig::default();
482 let url = format!("{}/feeds", config.base_url);
483
484 let response_text = client.get_with_params(&url, ¶ms).await?;
485
486 let response_data: serde_json::Value = serde_json::from_str(&response_text)
487 .map_err(|e| WebToolError::Parsing(format!("Invalid JSON response: {}", e)))?;
488
489 let data = response_data
490 .get("data")
491 .ok_or_else(|| WebToolError::Parsing("Missing 'data' field".to_string()))?;
492
493 let posts = data
494 .as_array()
495 .ok_or_else(|| WebToolError::Parsing("'data' is not an array".to_string()))?;
496
497 let mut mentions = Vec::new();
498 let mut total_sentiment = 0.0;
499
500 for post in posts.iter().take(limit as usize) {
501 let id = post
502 .get("id")
503 .and_then(|v| v.as_str())
504 .unwrap_or("unknown")
505 .to_string();
506
507 let influencer_username = post
508 .get("user_name")
509 .and_then(|v| v.as_str())
510 .unwrap_or("unknown")
511 .to_string();
512
513 let influencer_name = post
514 .get("user_display_name")
515 .and_then(|v| v.as_str())
516 .unwrap_or(&influencer_username)
517 .to_string();
518
519 let followers = post
520 .get("user_followers")
521 .and_then(|v| v.as_u64())
522 .unwrap_or(0);
523
524 let text = post
525 .get("content")
526 .and_then(|v| v.as_str())
527 .unwrap_or("")
528 .to_string();
529
530 let timestamp_str = post
531 .get("created_time")
532 .and_then(|v| v.as_str())
533 .unwrap_or("1970-01-01T00:00:00Z");
534
535 let timestamp = DateTime::parse_from_rfc3339(timestamp_str)
536 .unwrap_or_else(|_| {
537 DateTime::from_timestamp(0, 0)
538 .map(|dt| dt.into())
539 .unwrap_or_else(|| Utc::now().into())
540 })
541 .with_timezone(&Utc);
542
543 let platform = post
544 .get("type")
545 .and_then(|v| v.as_str())
546 .unwrap_or("twitter")
547 .to_string();
548
549 let likes = post
550 .get("interactions")
551 .and_then(|i| i.get("likes"))
552 .and_then(|v| v.as_u64())
553 .unwrap_or(0);
554 let shares = post
555 .get("interactions")
556 .and_then(|i| i.get("retweets"))
557 .and_then(|v| v.as_u64())
558 .unwrap_or(0);
559 let comments = post
560 .get("interactions")
561 .and_then(|i| i.get("replies"))
562 .and_then(|v| v.as_u64())
563 .unwrap_or(0);
564
565 let url = post
566 .get("url")
567 .and_then(|v| v.as_str())
568 .map(|s| s.to_string());
569
570 let sentiment = post
571 .get("sentiment")
572 .and_then(|v| v.as_f64())
573 .unwrap_or(0.0);
574 total_sentiment += sentiment;
575
576 mentions.push(InfluencerMention {
577 id,
578 influencer_username,
579 influencer_name,
580 followers,
581 text,
582 timestamp,
583 platform,
584 likes,
585 shares,
586 comments,
587 url,
588 sentiment,
589 });
590 }
591
592 let avg_sentiment = if mentions.is_empty() {
593 0.0
594 } else {
595 total_sentiment / mentions.len() as f64
596 };
597
598 debug!(
599 "Successfully fetched {} influencer mentions for {} with avg sentiment {:.2}",
600 mentions.len(),
601 token_symbol,
602 avg_sentiment
603 );
604
605 let total_mentions = mentions.len() as u64;
606
607 Ok(InfluencerMentionsResult {
608 symbol: token_symbol.to_uppercase(),
609 mentions,
610 total_mentions,
611 timeframe,
612 avg_sentiment,
613 })
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619 use chrono::{TimeZone, Utc};
620 use std::env;
621
622 #[test]
624 fn test_lunarcrush_config_default_without_env_var() {
625 env::remove_var(LUNARCRUSH_API_KEY);
627
628 let config = LunarCrushConfig::default();
629 assert_eq!(config.api_key, "");
630 assert_eq!(config.base_url, "https://api.lunarcrush.com/v2");
631 assert_eq!(config.rate_limit_per_minute, 60);
632 }
633
634 #[test]
635 fn test_lunarcrush_config_default_with_env_var() {
636 env::set_var(LUNARCRUSH_API_KEY, "test_api_key");
637
638 let config = LunarCrushConfig::default();
639 assert_eq!(config.api_key, "test_api_key");
640 assert_eq!(config.base_url, "https://api.lunarcrush.com/v2");
641 assert_eq!(config.rate_limit_per_minute, 60);
642
643 env::remove_var(LUNARCRUSH_API_KEY);
644 }
645
646 #[test]
647 fn test_lunarcrush_config_debug_trait() {
648 let config = LunarCrushConfig {
649 api_key: "test_key".to_string(),
650 base_url: "https://test.com".to_string(),
651 rate_limit_per_minute: 30,
652 };
653
654 let debug_str = format!("{:?}", config);
655 assert!(debug_str.contains("test_key"));
656 assert!(debug_str.contains("https://test.com"));
657 assert!(debug_str.contains("30"));
658 }
659
660 #[test]
661 fn test_lunarcrush_config_clone() {
662 let config = LunarCrushConfig {
663 api_key: "test_key".to_string(),
664 base_url: "https://test.com".to_string(),
665 rate_limit_per_minute: 30,
666 };
667
668 let cloned = config.clone();
669 assert_eq!(config.api_key, cloned.api_key);
670 assert_eq!(config.base_url, cloned.base_url);
671 assert_eq!(config.rate_limit_per_minute, cloned.rate_limit_per_minute);
672 }
673
674 #[test]
676 fn test_sentiment_data_serialization_and_deserialization() {
677 let mut platform_sentiment = HashMap::new();
678 platform_sentiment.insert("twitter".to_string(), 0.8);
679 platform_sentiment.insert("reddit".to_string(), 0.6);
680
681 let sentiment = SentimentData {
682 symbol: "BTC".to_string(),
683 social_score: 85.5,
684 sentiment_score: 0.7,
685 social_volume: 10000,
686 mentions: 15000,
687 influencer_posts: 250,
688 avg_social_volume: 9500.0,
689 social_volume_change: 15.2,
690 platform_sentiment,
691 trending_keywords: vec!["bitcoin".to_string(), "crypto".to_string()],
692 timestamp: Utc::now(),
693 };
694
695 let serialized = serde_json::to_string(&sentiment).unwrap();
696 assert!(serialized.contains("BTC"));
697 assert!(serialized.contains("85.5"));
698 assert!(serialized.contains("twitter"));
699 assert!(serialized.contains("bitcoin"));
700
701 let deserialized: SentimentData = serde_json::from_str(&serialized).unwrap();
702 assert_eq!(deserialized.symbol, "BTC");
703 assert_eq!(deserialized.social_score, 85.5);
704 assert_eq!(deserialized.platform_sentiment.len(), 2);
705 assert_eq!(deserialized.trending_keywords.len(), 2);
706 }
707
708 #[test]
709 fn test_sentiment_data_debug_trait() {
710 let sentiment = SentimentData {
711 symbol: "ETH".to_string(),
712 social_score: 90.0,
713 sentiment_score: 0.5,
714 social_volume: 5000,
715 mentions: 8000,
716 influencer_posts: 100,
717 avg_social_volume: 4800.0,
718 social_volume_change: -5.2,
719 platform_sentiment: HashMap::new(),
720 trending_keywords: vec![],
721 timestamp: Utc::now(),
722 };
723
724 let debug_str = format!("{:?}", sentiment);
725 assert!(debug_str.contains("ETH"));
726 assert!(debug_str.contains("90"));
727 }
728
729 #[test]
730 fn test_sentiment_data_clone() {
731 let sentiment = SentimentData {
732 symbol: "SOL".to_string(),
733 social_score: 75.0,
734 sentiment_score: 0.3,
735 social_volume: 3000,
736 mentions: 4000,
737 influencer_posts: 50,
738 avg_social_volume: 2900.0,
739 social_volume_change: 10.5,
740 platform_sentiment: HashMap::new(),
741 trending_keywords: vec!["solana".to_string()],
742 timestamp: Utc::now(),
743 };
744
745 let cloned = sentiment.clone();
746 assert_eq!(sentiment.symbol, cloned.symbol);
747 assert_eq!(sentiment.social_score, cloned.social_score);
748 assert_eq!(sentiment.trending_keywords, cloned.trending_keywords);
749 }
750
751 #[test]
753 fn test_trending_crypto_serialization_and_deserialization() {
754 let trending = TrendingCrypto {
755 symbol: "ETH".to_string(),
756 name: "Ethereum".to_string(),
757 market_rank: 2,
758 galaxy_score: 90.0,
759 alt_rank: 1,
760 social_score: 88.0,
761 price_usd: 2500.0,
762 price_change_24h: 5.2,
763 social_volume: 25000,
764 social_volume_change: 12.5,
765 market_cap: 300000000000,
766 volume_24h: 15000000000,
767 };
768
769 let serialized = serde_json::to_string(&trending).unwrap();
770 assert!(serialized.contains("ETH"));
771 assert!(serialized.contains("Ethereum"));
772 assert!(serialized.contains("90"));
773
774 let deserialized: TrendingCrypto = serde_json::from_str(&serialized).unwrap();
775 assert_eq!(deserialized.symbol, "ETH");
776 assert_eq!(deserialized.name, "Ethereum");
777 assert_eq!(deserialized.market_rank, 2);
778 }
779
780 #[test]
781 fn test_trending_crypto_debug_trait() {
782 let trending = TrendingCrypto {
783 symbol: "BTC".to_string(),
784 name: "Bitcoin".to_string(),
785 market_rank: 1,
786 galaxy_score: 95.0,
787 alt_rank: 1,
788 social_score: 92.0,
789 price_usd: 50000.0,
790 price_change_24h: -2.1,
791 social_volume: 50000,
792 social_volume_change: -8.3,
793 market_cap: 1000000000000,
794 volume_24h: 30000000000,
795 };
796
797 let debug_str = format!("{:?}", trending);
798 assert!(debug_str.contains("BTC"));
799 assert!(debug_str.contains("Bitcoin"));
800 assert!(debug_str.contains("95"));
801 }
802
803 #[test]
804 fn test_trending_crypto_clone() {
805 let trending = TrendingCrypto {
806 symbol: "ADA".to_string(),
807 name: "Cardano".to_string(),
808 market_rank: 5,
809 galaxy_score: 70.0,
810 alt_rank: 4,
811 social_score: 68.0,
812 price_usd: 1.5,
813 price_change_24h: 3.4,
814 social_volume: 8000,
815 social_volume_change: 15.7,
816 market_cap: 50000000000,
817 volume_24h: 2000000000,
818 };
819
820 let cloned = trending.clone();
821 assert_eq!(trending.symbol, cloned.symbol);
822 assert_eq!(trending.name, cloned.name);
823 assert_eq!(trending.market_rank, cloned.market_rank);
824 }
825
826 #[test]
828 fn test_influencer_mention_serialization_and_deserialization() {
829 let mention = InfluencerMention {
830 id: "12345".to_string(),
831 influencer_username: "crypto_guru".to_string(),
832 influencer_name: "Crypto Guru".to_string(),
833 followers: 100000,
834 text: "Bitcoin is looking bullish!".to_string(),
835 timestamp: Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap(),
836 platform: "twitter".to_string(),
837 likes: 500,
838 shares: 100,
839 comments: 50,
840 url: Some("https://twitter.com/post/12345".to_string()),
841 sentiment: 0.8,
842 };
843
844 let serialized = serde_json::to_string(&mention).unwrap();
845 assert!(serialized.contains("12345"));
846 assert!(serialized.contains("crypto_guru"));
847 assert!(serialized.contains("Bitcoin is looking bullish"));
848
849 let deserialized: InfluencerMention = serde_json::from_str(&serialized).unwrap();
850 assert_eq!(deserialized.id, "12345");
851 assert_eq!(deserialized.influencer_username, "crypto_guru");
852 assert_eq!(deserialized.followers, 100000);
853 }
854
855 #[test]
856 fn test_influencer_mention_without_url() {
857 let mention = InfluencerMention {
858 id: "67890".to_string(),
859 influencer_username: "trader_joe".to_string(),
860 influencer_name: "Trader Joe".to_string(),
861 followers: 50000,
862 text: "Market is volatile".to_string(),
863 timestamp: Utc::now(),
864 platform: "reddit".to_string(),
865 likes: 200,
866 shares: 25,
867 comments: 75,
868 url: None,
869 sentiment: -0.2,
870 };
871
872 let serialized = serde_json::to_string(&mention).unwrap();
873 let deserialized: InfluencerMention = serde_json::from_str(&serialized).unwrap();
874 assert_eq!(deserialized.url, None);
875 assert_eq!(deserialized.sentiment, -0.2);
876 }
877
878 #[test]
879 fn test_influencer_mention_debug_trait() {
880 let mention = InfluencerMention {
881 id: "test_id".to_string(),
882 influencer_username: "test_user".to_string(),
883 influencer_name: "Test User".to_string(),
884 followers: 1000,
885 text: "Test content".to_string(),
886 timestamp: Utc::now(),
887 platform: "twitter".to_string(),
888 likes: 10,
889 shares: 5,
890 comments: 2,
891 url: None,
892 sentiment: 0.0,
893 };
894
895 let debug_str = format!("{:?}", mention);
896 assert!(debug_str.contains("test_id"));
897 assert!(debug_str.contains("test_user"));
898 }
899
900 #[test]
901 fn test_influencer_mention_clone() {
902 let mention = InfluencerMention {
903 id: "clone_test".to_string(),
904 influencer_username: "clone_user".to_string(),
905 influencer_name: "Clone User".to_string(),
906 followers: 2000,
907 text: "Clone test".to_string(),
908 timestamp: Utc::now(),
909 platform: "reddit".to_string(),
910 likes: 20,
911 shares: 10,
912 comments: 5,
913 url: Some("test_url".to_string()),
914 sentiment: 0.5,
915 };
916
917 let cloned = mention.clone();
918 assert_eq!(mention.id, cloned.id);
919 assert_eq!(mention.influencer_username, cloned.influencer_username);
920 assert_eq!(mention.url, cloned.url);
921 }
922
923 #[test]
925 fn test_influencer_mentions_result_serialization_and_deserialization() {
926 let mentions = vec![InfluencerMention {
927 id: "1".to_string(),
928 influencer_username: "user1".to_string(),
929 influencer_name: "User One".to_string(),
930 followers: 1000,
931 text: "Content 1".to_string(),
932 timestamp: Utc::now(),
933 platform: "twitter".to_string(),
934 likes: 10,
935 shares: 5,
936 comments: 2,
937 url: None,
938 sentiment: 0.5,
939 }];
940
941 let result = InfluencerMentionsResult {
942 symbol: "BTC".to_string(),
943 mentions,
944 total_mentions: 1,
945 timeframe: "24h".to_string(),
946 avg_sentiment: 0.5,
947 };
948
949 let serialized = serde_json::to_string(&result).unwrap();
950 assert!(serialized.contains("BTC"));
951 assert!(serialized.contains("24h"));
952
953 let deserialized: InfluencerMentionsResult = serde_json::from_str(&serialized).unwrap();
954 assert_eq!(deserialized.symbol, "BTC");
955 assert_eq!(deserialized.total_mentions, 1);
956 assert_eq!(deserialized.timeframe, "24h");
957 }
958
959 #[test]
960 fn test_influencer_mentions_result_empty_mentions() {
961 let result = InfluencerMentionsResult {
962 symbol: "ETH".to_string(),
963 mentions: vec![],
964 total_mentions: 0,
965 timeframe: "7d".to_string(),
966 avg_sentiment: 0.0,
967 };
968
969 assert_eq!(result.mentions.len(), 0);
970 assert_eq!(result.total_mentions, 0);
971 assert_eq!(result.avg_sentiment, 0.0);
972 }
973
974 #[test]
975 fn test_influencer_mentions_result_debug_trait() {
976 let result = InfluencerMentionsResult {
977 symbol: "SOL".to_string(),
978 mentions: vec![],
979 total_mentions: 5,
980 timeframe: "30d".to_string(),
981 avg_sentiment: 0.3,
982 };
983
984 let debug_str = format!("{:?}", result);
985 assert!(debug_str.contains("SOL"));
986 assert!(debug_str.contains("30d"));
987 assert!(debug_str.contains("0.3"));
988 }
989
990 #[test]
991 fn test_influencer_mentions_result_clone() {
992 let result = InfluencerMentionsResult {
993 symbol: "DOGE".to_string(),
994 mentions: vec![],
995 total_mentions: 10,
996 timeframe: "24h".to_string(),
997 avg_sentiment: -0.1,
998 };
999
1000 let cloned = result.clone();
1001 assert_eq!(result.symbol, cloned.symbol);
1002 assert_eq!(result.total_mentions, cloned.total_mentions);
1003 assert_eq!(result.timeframe, cloned.timeframe);
1004 assert_eq!(result.avg_sentiment, cloned.avg_sentiment);
1005 }
1006
1007 #[tokio::test]
1009 async fn test_create_lunarcrush_client_missing_api_key() {
1010 env::remove_var(LUNARCRUSH_API_KEY);
1011
1012 let result = create_lunarcrush_client().await;
1013 assert!(result.is_err());
1014
1015 if let Err(WebToolError::Config(msg)) = result {
1016 assert_eq!(msg, "LUNARCRUSH_API_KEY environment variable not set");
1017 } else {
1018 panic!("Expected Config error");
1019 }
1020 }
1021
1022 #[tokio::test]
1023 async fn test_create_lunarcrush_client_with_empty_api_key() {
1024 env::set_var(LUNARCRUSH_API_KEY, "");
1025
1026 let result = create_lunarcrush_client().await;
1027 assert!(result.is_err());
1028
1029 env::remove_var(LUNARCRUSH_API_KEY);
1030 }
1031
1032 #[test]
1034 fn test_timeframe_validation_get_social_sentiment() {
1035 let valid_timeframes = ["1h", "24h", "7d", "30d"];
1037 let invalid_timeframes = ["2h", "1d", "1w", "1m", "invalid"];
1038
1039 for tf in valid_timeframes {
1040 assert!(["1h", "24h", "7d", "30d"].contains(&tf));
1041 }
1042
1043 for tf in invalid_timeframes {
1044 assert!(!["1h", "24h", "7d", "30d"].contains(&tf));
1045 }
1046 }
1047
1048 #[test]
1049 fn test_timeframe_validation_get_influencer_mentions() {
1050 let valid_timeframes = ["24h", "7d", "30d"];
1052 let invalid_timeframes = ["1h", "1d", "1w", "1m", "invalid"];
1053
1054 for tf in valid_timeframes {
1055 assert!(["24h", "7d", "30d"].contains(&tf));
1056 }
1057
1058 for tf in invalid_timeframes {
1059 assert!(!["24h", "7d", "30d"].contains(&tf));
1060 }
1061 }
1062
1063 #[test]
1064 fn test_sort_by_validation_get_trending_cryptos() {
1065 let valid_sort_options = ["galaxy_score", "social_volume", "alt_rank"];
1067 let invalid_sort_options = ["price", "market_cap", "invalid"];
1068
1069 for sort_by in valid_sort_options {
1070 assert!(["galaxy_score", "social_volume", "alt_rank"].contains(&sort_by));
1071 }
1072
1073 for sort_by in invalid_sort_options {
1074 assert!(!["galaxy_score", "social_volume", "alt_rank"].contains(&sort_by));
1075 }
1076 }
1077
1078 #[test]
1079 fn test_limit_clamping() {
1080 let test_cases = [
1082 (None, 10), (Some(0), 1), (Some(5), 5), (Some(25), 25), (Some(100), 50), ];
1088
1089 for (input, expected) in test_cases {
1090 let result = input.unwrap_or(10).clamp(1, 50);
1091 assert_eq!(result, expected);
1092 }
1093 }
1094
1095 #[test]
1096 fn test_limit_clamping_influencer_mentions() {
1097 let test_cases = [
1099 (None, 20), (Some(0), 1), (Some(10), 10), (Some(30), 30), (Some(100), 50), ];
1105
1106 for (input, expected) in test_cases {
1107 let result = input.unwrap_or(20).clamp(1, 50);
1108 assert_eq!(result, expected);
1109 }
1110 }
1111
1112 #[test]
1113 fn test_string_case_conversion() {
1114 let symbols = ["btc", "ETH", "SoL", "DOGE"];
1116 let expected = ["BTC", "ETH", "SOL", "DOGE"];
1117
1118 for (input, expected_output) in symbols.iter().zip(expected.iter()) {
1119 assert_eq!(input.to_uppercase(), *expected_output);
1120 }
1121 }
1122
1123 #[test]
1124 fn test_datetime_parsing_edge_cases() {
1125 use chrono::DateTime;
1126
1127 let valid_datetime = "2023-01-01T12:00:00Z";
1129 let parsed = DateTime::parse_from_rfc3339(valid_datetime);
1130 assert!(parsed.is_ok());
1131
1132 let invalid_datetime = "invalid-datetime";
1134 let parsed = DateTime::parse_from_rfc3339(invalid_datetime);
1135 assert!(parsed.is_err());
1136
1137 let fallback_timestamp = DateTime::parse_from_rfc3339("invalid")
1139 .unwrap_or_else(|_| {
1140 DateTime::from_timestamp(0, 0)
1141 .map(|dt| dt.into())
1142 .unwrap_or_else(|| Utc::now().into())
1143 })
1144 .with_timezone(&Utc);
1145
1146 assert!(fallback_timestamp.timestamp() >= 0);
1148 }
1149
1150 #[test]
1151 fn test_average_sentiment_calculation() {
1152 let sentiments: Vec<f64> = vec![];
1156 let avg = if sentiments.is_empty() {
1157 0.0
1158 } else {
1159 sentiments.iter().sum::<f64>() / sentiments.len() as f64
1160 };
1161 assert_eq!(avg, 0.0);
1162
1163 let sentiments = vec![0.5];
1165 let avg = sentiments.iter().sum::<f64>() / sentiments.len() as f64;
1166 assert_eq!(avg, 0.5);
1167
1168 let sentiments = vec![0.8, -0.2, 0.1, 0.3];
1170 let avg = sentiments.iter().sum::<f64>() / sentiments.len() as f64;
1171 assert_eq!(avg, 0.25);
1172
1173 let sentiments = vec![0.9, 0.8, 0.7];
1175 let avg = sentiments.iter().sum::<f64>() / sentiments.len() as f64;
1176 assert!((avg - 0.8).abs() < f64::EPSILON);
1177
1178 let sentiments = vec![-0.5, -0.3, -0.2];
1180 let avg = sentiments.iter().sum::<f64>() / sentiments.len() as f64;
1181 assert!((avg - (-1.0 / 3.0)).abs() < 0.01);
1182 }
1183
1184 #[test]
1185 fn test_json_field_extraction_patterns() {
1186 use serde_json::json;
1187
1188 let test_json = json!({
1190 "galaxy_score": 85.5,
1191 "sentiment": null,
1192 "social_volume": "not_a_number",
1193 "missing_field": null
1194 });
1195
1196 let galaxy_score = test_json
1198 .get("galaxy_score")
1199 .and_then(|v| v.as_f64())
1200 .unwrap_or(0.0);
1201 assert_eq!(galaxy_score, 85.5);
1202
1203 let sentiment = test_json
1205 .get("sentiment")
1206 .and_then(|v| v.as_f64())
1207 .unwrap_or(0.0);
1208 assert_eq!(sentiment, 0.0);
1209
1210 let social_volume = test_json
1212 .get("social_volume")
1213 .and_then(|v| v.as_u64())
1214 .unwrap_or(0);
1215 assert_eq!(social_volume, 0);
1216
1217 let missing = test_json
1219 .get("missing_field")
1220 .and_then(|v| v.as_f64())
1221 .unwrap_or(99.9);
1222 assert_eq!(missing, 99.9);
1223 }
1224
1225 #[test]
1226 fn test_platform_sentiment_extraction() {
1227 use serde_json::json;
1228
1229 let test_data = json!({
1230 "platform_sentiment": {
1231 "twitter": 0.8,
1232 "reddit": 0.6,
1233 "youtube": "invalid"
1234 }
1235 });
1236
1237 let mut platform_sentiment = HashMap::new();
1238 if let Some(platforms) = test_data.get("platform_sentiment") {
1239 if let Some(twitter_sentiment) = platforms.get("twitter").and_then(|v| v.as_f64()) {
1240 platform_sentiment.insert("twitter".to_string(), twitter_sentiment);
1241 }
1242 if let Some(reddit_sentiment) = platforms.get("reddit").and_then(|v| v.as_f64()) {
1243 platform_sentiment.insert("reddit".to_string(), reddit_sentiment);
1244 }
1245 if let Some(youtube_sentiment) = platforms.get("youtube").and_then(|v| v.as_f64()) {
1246 platform_sentiment.insert("youtube".to_string(), youtube_sentiment);
1247 }
1248 }
1249
1250 assert_eq!(platform_sentiment.len(), 2);
1251 assert_eq!(platform_sentiment.get("twitter"), Some(&0.8));
1252 assert_eq!(platform_sentiment.get("reddit"), Some(&0.6));
1253 assert_eq!(platform_sentiment.get("youtube"), None);
1254 }
1255
1256 #[test]
1257 fn test_keywords_extraction() {
1258 use serde_json::json;
1259
1260 let test_data = json!({
1262 "keywords": ["bitcoin", "crypto", "blockchain"]
1263 });
1264
1265 let trending_keywords: Vec<String> = test_data
1266 .get("keywords")
1267 .and_then(|v| v.as_array())
1268 .map(|arr| {
1269 arr.iter()
1270 .filter_map(|kw| kw.as_str().map(|s| s.to_string()))
1271 .collect()
1272 })
1273 .unwrap_or_default();
1274
1275 assert_eq!(trending_keywords.len(), 3);
1276 assert!(trending_keywords.contains(&"bitcoin".to_string()));
1277
1278 let test_data_empty = json!({});
1280 let trending_keywords_empty: Vec<String> = test_data_empty
1281 .get("keywords")
1282 .and_then(|v| v.as_array())
1283 .map(|arr| {
1284 arr.iter()
1285 .filter_map(|kw| kw.as_str().map(|s| s.to_string()))
1286 .collect()
1287 })
1288 .unwrap_or_default();
1289
1290 assert_eq!(trending_keywords_empty.len(), 0);
1291
1292 let test_data_mixed = json!({
1294 "keywords": ["bitcoin", 123, null, "ethereum"]
1295 });
1296
1297 let trending_keywords_mixed: Vec<String> = test_data_mixed
1298 .get("keywords")
1299 .and_then(|v| v.as_array())
1300 .map(|arr| {
1301 arr.iter()
1302 .filter_map(|kw| kw.as_str().map(|s| s.to_string()))
1303 .collect()
1304 })
1305 .unwrap_or_default();
1306
1307 assert_eq!(trending_keywords_mixed.len(), 2);
1308 assert!(trending_keywords_mixed.contains(&"bitcoin".to_string()));
1309 assert!(trending_keywords_mixed.contains(&"ethereum".to_string()));
1310 }
1311
1312 #[test]
1313 fn test_take_limit_behavior() {
1314 let test_vec = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
1316
1317 let result: Vec<_> = test_vec.iter().take(3).collect();
1319 assert_eq!(result.len(), 3);
1320
1321 let result: Vec<_> = test_vec.iter().take(20).collect();
1323 assert_eq!(result.len(), 10); let result: Vec<_> = test_vec.iter().take(0).collect();
1327 assert_eq!(result.len(), 0);
1328 }
1329
1330 #[test]
1331 fn test_const_api_key_name() {
1332 assert_eq!(LUNARCRUSH_API_KEY, "LUNARCRUSH_API_KEY");
1334 }
1335}