Skip to main content

finance_query/adapters/yahoo/discovery/
search.rs

1/// Search endpoint
2///
3/// Search for quotes, news, and research reports on Yahoo Finance.
4use crate::adapters::yahoo::client::YahooClient;
5use crate::adapters::yahoo::endpoints::api;
6use crate::constants::Region;
7use crate::error::Result;
8use tracing::info;
9
10/// Search configuration options
11#[derive(Debug, Clone)]
12pub struct SearchOptions {
13    /// Maximum number of quote results (default: 10)
14    pub quotes_count: u32,
15    /// Maximum number of news results (default: 0 = disabled)
16    pub news_count: u32,
17    /// Enable fuzzy matching for typos (default: false)
18    pub enable_fuzzy_query: bool,
19    /// Enable logo URLs in results (default: true)
20    pub enable_logo_url: bool,
21    /// Enable research reports in results (default: false)
22    pub enable_research_reports: bool,
23    /// Enable cultural assets (NFT indices) in results (default: false)
24    pub enable_cultural_assets: bool,
25    /// Recommended count (default: 5)
26    pub recommend_count: u32,
27    /// Region for language/region settings. If None, uses client default.
28    pub region: Option<Region>,
29}
30
31impl Default for SearchOptions {
32    fn default() -> Self {
33        Self {
34            quotes_count: 10,
35            news_count: 0,
36            enable_fuzzy_query: false,
37            enable_logo_url: true,
38            enable_research_reports: false,
39            enable_cultural_assets: false,
40            recommend_count: 5,
41            region: None,
42        }
43    }
44}
45
46impl SearchOptions {
47    /// Create new search options with defaults
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    /// Set maximum quote results
53    pub fn quotes_count(mut self, count: u32) -> Self {
54        self.quotes_count = count;
55        self
56    }
57
58    /// Set maximum news results
59    pub fn news_count(mut self, count: u32) -> Self {
60        self.news_count = count;
61        self
62    }
63
64    /// Enable or disable fuzzy query matching
65    pub fn enable_fuzzy_query(mut self, enable: bool) -> Self {
66        self.enable_fuzzy_query = enable;
67        self
68    }
69
70    /// Enable or disable logo URLs
71    pub fn enable_logo_url(mut self, enable: bool) -> Self {
72        self.enable_logo_url = enable;
73        self
74    }
75
76    /// Enable or disable research reports
77    pub fn enable_research_reports(mut self, enable: bool) -> Self {
78        self.enable_research_reports = enable;
79        self
80    }
81
82    /// Enable or disable cultural assets (NFT indices)
83    pub fn enable_cultural_assets(mut self, enable: bool) -> Self {
84        self.enable_cultural_assets = enable;
85        self
86    }
87
88    /// Set recommend count
89    pub fn recommend_count(mut self, count: u32) -> Self {
90        self.recommend_count = count;
91        self
92    }
93
94    /// Set region for language settings
95    pub fn region(mut self, region: Region) -> Self {
96        self.region = Some(region);
97        self
98    }
99}
100
101/// Search for quotes, news, and research reports
102///
103/// # Arguments
104///
105/// * `client` - The Yahoo Finance client
106/// * `query` - Search query string
107/// * `options` - Search configuration options
108///
109/// # Example
110///
111/// ```ignore
112/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
113/// # let client = finance_query::YahooClient::new(Default::default()).await?;
114/// use finance_query::endpoints::search::{fetch, SearchOptions};
115/// let options = SearchOptions::new().quotes_count(10).news_count(5);
116/// let results = client.search("Apple", &options).await?;
117/// # Ok(())
118/// # }
119/// ```
120/// Same request as [`fetch`], but hands back the raw body so callers can
121/// deserialize straight into a typed struct instead of via `serde_json::Value`.
122pub(crate) async fn fetch_bytes(
123    client: &YahooClient,
124    query: &str,
125    options: &SearchOptions,
126) -> Result<impl std::ops::Deref<Target = [u8]>> {
127    if query.trim().is_empty() {
128        return Err(crate::error::FinanceError::InvalidParameter {
129            param: "query".to_string(),
130            reason: "Empty search query".to_string(),
131        });
132    }
133
134    info!("Searching for: {} (options: {:?})", query, options);
135
136    let quotes_count = options.quotes_count.to_string();
137    let news_count = options.news_count.to_string();
138    let fuzzy = options.enable_fuzzy_query.to_string();
139    let logo = options.enable_logo_url.to_string();
140    let research = options.enable_research_reports.to_string();
141    let cultural = options.enable_cultural_assets.to_string();
142    let recommend = options.recommend_count.to_string();
143
144    // Use provided regions's lang/code or fall back to client config
145    let lang = options
146        .region
147        .as_ref()
148        .map(|c| c.lang().to_string())
149        .unwrap_or_else(|| client.config().lang.clone());
150    let region = options
151        .region
152        .as_ref()
153        .map(|c| c.region().to_string())
154        .unwrap_or_else(|| client.config().region.clone());
155
156    let params = [
157        ("q", query),
158        ("lang", &lang),
159        ("region", &region),
160        ("quotesCount", &quotes_count),
161        ("newsCount", &news_count),
162        ("enableFuzzyQuery", &fuzzy),
163        ("enableLogoUrl", &logo),
164        ("enableResearchReports", &research),
165        ("enableCulturalAssets", &cultural),
166        ("recommendedCount", &recommend),
167        ("listsCount", "0"),         // Disable Yahoo-specific lists
168        ("enableNavLinks", "false"), // Disable Yahoo navigation links
169        ("enableEnhancedTrivialQuery", "true"),
170    ];
171
172    let response = client.request_with_params(api::SEARCH, &params).await?;
173
174    Ok(response.bytes().await?)
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::adapters::yahoo::client::ClientConfig;
181
182    #[tokio::test]
183    #[ignore] // Requires network access
184    async fn test_fetch_search() {
185        let client = YahooClient::new(ClientConfig::default()).await.unwrap();
186        let options = SearchOptions::new().quotes_count(5);
187        let bytes = fetch_bytes(&client, "Apple", &options).await.unwrap();
188        let results: crate::models::discovery::search::SearchResults =
189            serde_json::from_slice(&bytes).unwrap();
190        assert!(!results.quotes.0.is_empty());
191    }
192
193    #[tokio::test]
194    #[ignore] // Requires network access
195    async fn test_fetch_search_with_news() {
196        let client = YahooClient::new(ClientConfig::default()).await.unwrap();
197        let options = SearchOptions::new()
198            .quotes_count(5)
199            .news_count(3)
200            .enable_research_reports(true);
201        let bytes = fetch_bytes(&client, "NVDA", &options).await.unwrap();
202        let results: crate::models::discovery::search::SearchResults =
203            serde_json::from_slice(&bytes).unwrap();
204        assert!(!results.quotes.0.is_empty());
205    }
206
207    #[test]
208    fn search_results_parse_from_slice() {
209        use crate::models::discovery::search::SearchResults;
210
211        // Realistic minimal search payload: top-level fields plus one quote
212        // that only carries the always-present `symbol`, mirroring a partial
213        // Yahoo response missing most optional fields.
214        let body = br#"{
215            "count": 2,
216            "quotes": [
217                {"symbol": "AAPL", "shortName": "Apple Inc.", "quoteType": "EQUITY"},
218                {"symbol": "AAPU"}
219            ],
220            "news": [],
221            "totalTime": 42
222        }"#;
223
224        let parsed: SearchResults = serde_json::from_slice(body).unwrap();
225        assert_eq!(parsed.result_count(), 2);
226        assert_eq!(parsed.quotes.len(), 2);
227        assert_eq!(parsed.quotes[0].symbol, "AAPL");
228        assert_eq!(parsed.quotes[1].short_name, None);
229        assert!(parsed.news.is_empty());
230    }
231
232    #[tokio::test]
233    #[ignore = "requires network access - validation tested in common::tests"]
234    async fn test_empty_query() {
235        let client = YahooClient::new(ClientConfig::default()).await.unwrap();
236        let options = SearchOptions::new();
237        let result = fetch_bytes(&client, "", &options).await;
238        assert!(result.is_err());
239    }
240}