gwelle 0.1.0

Lightweight Rust client for the Google Trends API
Documentation
use crate::client::TrendsClient;
use crate::models::{
    ExploreRequest, GeoResolution, InterestByRegion, InterestOverTime, RelatedQueries,
};
use crate::Result;
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct AgentTrendReport {
    pub keyword_trends: String,
    pub regional_hotspots: String,
    pub related_queries: String,
}

impl AgentTrendReport {
    pub fn to_llm_markdown(&self) -> String {
        format!(
            "## Google Trends Market Report\n\n### Interest Over Time\n{}\n\n### Regional Hotspots\n{}\n\n### Related Queries\n{}",
            self.keyword_trends, self.regional_hotspots, self.related_queries
        )
    }
}

pub struct GwelleAgent {
    client: TrendsClient,
}

impl GwelleAgent {
    pub async fn new() -> Result<Self> {
        let cookies = crate::session::bootstrap_session().await?;
        let client = TrendsClient::new(&cookies, "en-US", -60)?;
        Ok(Self { client })
    }

    pub async fn fetch_report(
        &self,
        keywords: Vec<String>,
        geo: &str,
        timeframe: &str,
    ) -> Result<AgentTrendReport> {
        let req = ExploreRequest {
            keywords: keywords.clone(),
            geo: geo.to_string(),
            timeframe: timeframe.to_string(),
            category: 0,
            property: "".to_string(),
        };

        let explore = self.client.explore(&req).await?;

        // 1. Fetch Interest Over Time
        let keyword_trends = if let Some(token) = &explore.interest_over_time {
            let data = self.client.interest_over_time(token).await?;
            format_interest_over_time(&data, &keywords)
        } else {
            return Err(crate::TrendsError::TokenNotFound(
                "No timeline capability available.".into(),
            ));
        };

        // 2. Fetch Geo Maps
        let regional_hotspots = if let Some(token) = &explore.interest_by_region {
            let res = if geo.is_empty() {
                GeoResolution::Country
            } else {
                GeoResolution::Region
            };
            let geo_data = self.client.interest_by_region(token, res).await?;
            format_geo_map(&geo_data)
        } else {
            return Err(crate::TrendsError::TokenNotFound(
                "No regional capability available.".into(),
            ));
        };

        // 3. Fetch Related Queries
        let related_queries = if let Some(token) = &explore.related_queries {
            let data = self.client.related_queries(token).await?;
            format_related_queries(&data)
        } else {
            return Err(crate::TrendsError::TokenNotFound(
                "No related queries capability available.".into(),
            ));
        };

        Ok(AgentTrendReport {
            keyword_trends,
            regional_hotspots,
            related_queries,
        })
    }
}

// ----------------------------------------------------
// Formatting Utilities for Token Compression
// ----------------------------------------------------

fn format_interest_over_time(data: &InterestOverTime, keywords: &[String]) -> String {
    if data.timeline_data.is_empty() {
        return "No sufficient trend volume data available.".to_string();
    }

    let mut out = String::new();
    let n = data.timeline_data.len();

    // We compute peak, low, and current for each keyword to save LLM reasoning tokens!
    for (idx, keyword) in keywords.iter().enumerate() {
        let mut max_val: Option<u32> = None;
        let mut min_val: Option<u32> = None;
        let mut max_date = String::new();
        let mut min_date = String::new();

        for point in &data.timeline_data {
            if let Some(&val) = point.values.get(idx) {
                if max_val.is_none_or(|current| val > current) {
                    max_val = Some(val);
                    max_date = point.formatted_time.clone();
                }
                if min_val.is_none_or(|current| val < current) {
                    min_val = Some(val);
                    min_date = point.formatted_time.clone();
                }
            }
        }

        let (max_val, min_val) = match (max_val, min_val) {
            (Some(max_val), Some(min_val)) => (max_val, min_val),
            _ => {
                out.push_str(&format!("**Keyword:** `{}`\n", keyword));
                out.push_str("- No timeline values available.\n\n");
                continue;
            }
        };

        let current_val = data
            .timeline_data
            .last()
            .and_then(|pt| pt.values.get(idx).copied())
            .unwrap_or(0);

        out.push_str(&format!("**Keyword:** `{}`\n", keyword));
        out.push_str(&format!("- Current Status (Latest): {}/100\n", current_val));
        out.push_str(&format!(
            "- Peak Volatility: {}/100 on {}\n",
            max_val, max_date
        ));
        out.push_str(&format!(
            "- Lowest Point: {}/100 on {}\n",
            min_val, min_date
        ));

        // Let's summarize the curve by breaking it down into a tiny 4-point sparkline equivalent
        if n >= 4 {
            let start = data.timeline_data[0].values.get(idx).unwrap_or(&0);
            let q1 = data.timeline_data[n / 4].values.get(idx).unwrap_or(&0);
            let q2 = data.timeline_data[n / 2].values.get(idx).unwrap_or(&0);
            let q3 = data.timeline_data[3 * n / 4].values.get(idx).unwrap_or(&0);
            out.push_str(&format!(
                "- Macro Curve (Start -> Q1 -> Q2 -> Q3 -> End): {} -> {} -> {} -> {} -> {}\n",
                start, q1, q2, q3, current_val
            ));
        }
        out.push('\n');
    }

    out
}

fn format_geo_map(geo_data: &InterestByRegion) -> String {
    let mut out = String::new();
    // Sort by highest value (assuming values[0] is the main value)
    let mut sorted_geo = geo_data.geo_map_data.clone();
    sorted_geo.sort_by(|a, b| {
        b.values
            .first()
            .unwrap_or(&0)
            .cmp(a.values.first().unwrap_or(&0))
    });

    out.push_str("Top 5 Penetration Regions:\n");
    for region in sorted_geo.iter().take(5) {
        if let Some(v) = region.values.first() {
            if *v > 0 {
                out.push_str(&format!(
                    "- {} ({}): {}/100\n",
                    region.geo_name, region.geo_code, v
                ));
            }
        }
    }
    out
}

fn format_related_queries(data: &RelatedQueries) -> String {
    let mut out = String::new();
    out.push_str("**Top Search Correlates:**\n");
    for q in data.top.iter().take(5) {
        out.push_str(&format!("- {} (Relevance: {})\n", q.query, q.value));
    }

    out.push_str("\n**Rising / Breakout Topics:**\n");
    for q in data.rising.iter().take(5) {
        out.push_str(&format!("- {} (Growth: {})\n", q.query, q.value));
    }
    out
}