riglr_web_tools/
lunarcrush.rs

1//! LunarCrush social analytics integration for cryptocurrency sentiment analysis
2//!
3//! This module provides advanced social analytics capabilities by integrating with LunarCrush,
4//! enabling AI agents to access comprehensive social sentiment data, influencer tracking,
5//! and trending cryptocurrency analysis.
6
7use 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/// LunarCrush API configuration
19#[derive(Debug, Clone)]
20pub struct LunarCrushConfig {
21    /// LunarCrush API key
22    pub api_key: String,
23    /// API base URL (default: https://api.lunarcrush.com/v2)
24    pub base_url: String,
25    /// Rate limit per minute (default: 60)
26    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/// Social sentiment data for a cryptocurrency
40#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
41pub struct SentimentData {
42    /// Cryptocurrency symbol (e.g., "BTC", "ETH")
43    pub symbol: String,
44    /// Overall social score (0-100)
45    pub social_score: f64,
46    /// Sentiment score (-1 to 1, where 1 is very positive)
47    pub sentiment_score: f64,
48    /// Social volume (number of mentions)
49    pub social_volume: u64,
50    /// Total mentions across all platforms
51    pub mentions: u64,
52    /// Number of posts from influencers
53    pub influencer_posts: u64,
54    /// Average social volume over the timeframe
55    pub avg_social_volume: f64,
56    /// Social volume change percentage
57    pub social_volume_change: f64,
58    /// Sentiment breakdown by platform
59    pub platform_sentiment: HashMap<String, f64>,
60    /// Trending keywords associated with the token
61    pub trending_keywords: Vec<String>,
62    /// Data timestamp
63    pub timestamp: DateTime<Utc>,
64}
65
66/// Trending cryptocurrency with social metrics
67#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
68pub struct TrendingCrypto {
69    /// Cryptocurrency symbol
70    pub symbol: String,
71    /// Full name of the cryptocurrency
72    pub name: String,
73    /// Current market rank
74    pub market_rank: u32,
75    /// Galaxy score (LunarCrush proprietary metric)
76    pub galaxy_score: f64,
77    /// AltRank (alternative ranking metric)
78    pub alt_rank: u32,
79    /// Social score
80    pub social_score: f64,
81    /// Price in USD
82    pub price_usd: f64,
83    /// 24h price change percentage
84    pub price_change_24h: f64,
85    /// Social volume
86    pub social_volume: u64,
87    /// Social volume change percentage
88    pub social_volume_change: f64,
89    /// Market cap in USD
90    pub market_cap: u64,
91    /// 24h trading volume
92    pub volume_24h: u64,
93}
94
95/// Influencer mention data
96#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
97pub struct InfluencerMention {
98    /// Unique mention ID
99    pub id: String,
100    /// Influencer username
101    pub influencer_username: String,
102    /// Influencer display name
103    pub influencer_name: String,
104    /// Number of followers
105    pub followers: u64,
106    /// Post content/text
107    pub text: String,
108    /// Post timestamp
109    pub timestamp: DateTime<Utc>,
110    /// Platform (Twitter, Reddit, etc.)
111    pub platform: String,
112    /// Engagement metrics
113    pub likes: u64,
114    /// Number of shares/retweets
115    pub shares: u64,
116    /// Number of comments
117    pub comments: u64,
118    /// Post URL
119    pub url: Option<String>,
120    /// Sentiment score for this specific mention
121    pub sentiment: f64,
122}
123
124/// Result containing multiple influencer mentions
125#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
126pub struct InfluencerMentionsResult {
127    /// Token symbol that was searched
128    pub symbol: String,
129    /// List of influencer mentions
130    pub mentions: Vec<InfluencerMention>,
131    /// Total number of mentions found
132    pub total_mentions: u64,
133    /// Timeframe of the search
134    pub timeframe: String,
135    /// Average sentiment across all mentions
136    pub avg_sentiment: f64,
137}
138
139/// Helper function to get LunarCrush API key from ApplicationContext
140fn 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
154/// Creates a LunarCrush API client with context
155async 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/// Creates a LunarCrush API client
164#[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]
180/// Get social sentiment data for a cryptocurrency from LunarCrush
181///
182/// This tool provides comprehensive social analytics for any cryptocurrency,
183/// including sentiment scores, social volume, and platform-specific data.
184///
185/// # Arguments
186/// * `symbol` - Cryptocurrency symbol (e.g., "BTC", "ETH", "SOL")
187/// * `timeframe` - Optional timeframe for analysis ("1h", "24h", "7d", "30d"). Defaults to "24h"
188///
189/// # Returns
190/// Detailed sentiment analysis including scores, volume metrics, and trending keywords
191pub 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    // Validate timeframe
206    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, &params).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    // Extract asset data from the response
226    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    // Parse the asset data
239    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    // Extract platform sentiment breakdown
269    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    // Extract trending keywords
280    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]
311/// Get trending cryptocurrencies by social metrics from LunarCrush
312///
313/// This tool identifies the most trending cryptocurrencies based on social media activity,
314/// sentiment, and LunarCrush's proprietary Galaxy Score.
315///
316/// # Arguments
317/// * `limit` - Maximum number of trending cryptocurrencies to return (1-50). Defaults to 10
318/// * `sort_by` - Sort metric: "galaxy_score", "social_volume", "alt_rank". Defaults to "galaxy_score"
319///
320/// # Returns
321/// List of trending cryptocurrencies with social and market metrics
322pub 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    // Validate sort_by parameter
336    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, &params).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]
440/// Get influencer mentions for a specific token from LunarCrush
441///
442/// This tool tracks mentions from cryptocurrency influencers and high-follower accounts,
443/// providing insights into what key opinion leaders are saying about specific tokens.
444///
445/// # Arguments
446/// * `token_symbol` - Cryptocurrency symbol to search for (e.g., "BTC", "ETH", "SOL")
447/// * `limit` - Maximum number of mentions to return (1-50). Defaults to 20
448/// * `timeframe` - Time period to search: "24h", "7d", "30d". Defaults to "24h"
449///
450/// # Returns
451/// Collection of influencer mentions with engagement metrics and sentiment analysis
452pub 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    // Validate timeframe
467    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, &params).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    // Tests for LunarCrushConfig
623    #[test]
624    fn test_lunarcrush_config_default_without_env_var() {
625        // Remove the env var if it exists
626        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    // Tests for SentimentData
675    #[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    // Tests for TrendingCrypto
752    #[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    // Tests for InfluencerMention
827    #[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    // Tests for InfluencerMentionsResult
924    #[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    // Tests for create_lunarcrush_client function
1008    #[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 timeframe validation in various functions
1033    #[test]
1034    fn test_timeframe_validation_get_social_sentiment() {
1035        // This tests the validation logic that would be used in get_social_sentiment
1036        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        // This tests the validation logic that would be used in get_influencer_mentions
1051        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        // This tests the validation logic that would be used in get_trending_cryptos
1066        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        // Test limit clamping logic used in get_trending_cryptos
1081        let test_cases = [
1082            (None, 10),      // Default case
1083            (Some(0), 1),    // Below minimum
1084            (Some(5), 5),    // Valid value
1085            (Some(25), 25),  // Valid value
1086            (Some(100), 50), // Above maximum
1087        ];
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        // Test limit clamping logic used in get_influencer_mentions
1098        let test_cases = [
1099            (None, 20),      // Default case
1100            (Some(0), 1),    // Below minimum
1101            (Some(10), 10),  // Valid value
1102            (Some(30), 30),  // Valid value
1103            (Some(100), 50), // Above maximum
1104        ];
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        // Test the string case conversion logic used throughout the module
1115        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        // Test valid RFC3339 datetime
1128        let valid_datetime = "2023-01-01T12:00:00Z";
1129        let parsed = DateTime::parse_from_rfc3339(valid_datetime);
1130        assert!(parsed.is_ok());
1131
1132        // Test invalid datetime format
1133        let invalid_datetime = "invalid-datetime";
1134        let parsed = DateTime::parse_from_rfc3339(invalid_datetime);
1135        assert!(parsed.is_err());
1136
1137        // Test fallback behavior (mimics the code in get_influencer_mentions)
1138        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        // Should be either epoch time or current time, both valid
1147        assert!(fallback_timestamp.timestamp() >= 0);
1148    }
1149
1150    #[test]
1151    fn test_average_sentiment_calculation() {
1152        // Test sentiment average calculation logic used in get_influencer_mentions
1153
1154        // Empty case
1155        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        // Single value
1164        let sentiments = vec![0.5];
1165        let avg = sentiments.iter().sum::<f64>() / sentiments.len() as f64;
1166        assert_eq!(avg, 0.5);
1167
1168        // Multiple values
1169        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        // All positive
1174        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        // All negative
1179        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        // Test the pattern used for extracting values with defaults
1189        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        // Test f64 extraction with default
1197        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        // Test f64 extraction with null value
1204        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        // Test u64 extraction with invalid string
1211        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        // Test missing field
1218        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        // Test valid keywords array
1261        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        // Test missing keywords field
1279        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        // Test keywords with mixed types
1293        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        // Test the behavior of iterating with take() as used in the API functions
1315        let test_vec = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
1316
1317        // Take fewer items than available
1318        let result: Vec<_> = test_vec.iter().take(3).collect();
1319        assert_eq!(result.len(), 3);
1320
1321        // Take more items than available
1322        let result: Vec<_> = test_vec.iter().take(20).collect();
1323        assert_eq!(result.len(), 10); // Should only get what's available
1324
1325        // Take zero items
1326        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        // Test that the constant is correctly defined
1333        assert_eq!(LUNARCRUSH_API_KEY, "LUNARCRUSH_API_KEY");
1334    }
1335}