From 1dc7bb3391855d226cfa5333b586b4573c1eab73 Mon Sep 17 00:00:00 2001 From: francy51 Date: Sat, 4 Apr 2026 14:09:10 -0400 Subject: [PATCH] Reduce live search requests for exact symbol lookups --- .../src-tauri/src/terminal/command_service.rs | 163 +++++++++++++----- .../src-tauri/src/terminal/yahoo_finance.rs | 13 +- 2 files changed, 128 insertions(+), 48 deletions(-) diff --git a/MosaicIQ/src-tauri/src/terminal/command_service.rs b/MosaicIQ/src-tauri/src/terminal/command_service.rs index 7f6a89e..54f5ac2 100644 --- a/MosaicIQ/src-tauri/src/terminal/command_service.rs +++ b/MosaicIQ/src-tauri/src/terminal/command_service.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use crate::terminal::mock_data::load_mock_financial_data; use crate::terminal::yahoo_finance::{ - SecurityLookup, SecurityLookupError, SecurityMatch, YahooFinanceLookup, + SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch, YahooFinanceLookup, }; use crate::terminal::{ ChatCommandRequest, ExecuteTerminalCommandRequest, MockFinancialData, PanelPayload, @@ -17,7 +17,10 @@ pub struct TerminalCommandService { impl Default for TerminalCommandService { fn default() -> Self { - Self::with_dependencies(load_mock_financial_data(), Arc::new(YahooFinanceLookup::default())) + Self::with_dependencies( + load_mock_financial_data(), + Arc::new(YahooFinanceLookup::default()), + ) } } @@ -64,6 +67,17 @@ impl TerminalCommandService { }; } + if looks_like_symbol_query(query) { + return self + .load_exact_symbol_match(SecurityMatch { + symbol: query.trim().to_uppercase(), + name: None, + exchange: None, + kind: SecurityKind::Equity, + }) + .await; + } + let matches = match self.security_lookup.search(query).await { Ok(matches) => matches .into_iter() @@ -88,20 +102,7 @@ impl TerminalCommandService { } if let Some(selected_match) = select_exact_symbol_match(query, &matches) { - let selected_symbol = selected_match.symbol.clone(); - return match self.security_lookup.load_company(&selected_match).await { - Ok(company) => TerminalCommandResponse::Panel { - panel: PanelPayload::Company { data: company }, - }, - Err(SecurityLookupError::DetailUnavailable { symbol }) => { - TerminalCommandResponse::Text { - content: format!("Live security data unavailable for \"{symbol}\"."), - } - } - Err(SecurityLookupError::SearchUnavailable) => TerminalCommandResponse::Text { - content: format!("Live security data unavailable for \"{selected_symbol}\"."), - }, - }; + return self.load_exact_symbol_match(selected_match).await; } TerminalCommandResponse::Text { @@ -155,6 +156,39 @@ impl TerminalCommandService { }, } } + + async fn load_exact_symbol_match( + &self, + security_match: SecurityMatch, + ) -> TerminalCommandResponse { + let selected_symbol = security_match.symbol.clone(); + + match self.security_lookup.load_company(&security_match).await { + Ok(company) => TerminalCommandResponse::Panel { + panel: PanelPayload::Company { data: company }, + }, + Err(SecurityLookupError::DetailUnavailable { symbol }) => { + TerminalCommandResponse::Text { + content: format!("Live security data unavailable for \"{symbol}\"."), + } + } + Err(SecurityLookupError::SearchUnavailable) => TerminalCommandResponse::Text { + content: format!("Live security data unavailable for \"{selected_symbol}\"."), + }, + } + } +} + +fn looks_like_symbol_query(query: &str) -> bool { + let trimmed = query.trim(); + + !trimmed.is_empty() + && !trimmed.contains(char::is_whitespace) + && trimmed.len() <= 10 + && trimmed == trimmed.to_ascii_uppercase() + && trimmed.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '^' | '=') + }) } fn select_exact_symbol_match(query: &str, matches: &[SecurityMatch]) -> Option { @@ -227,6 +261,7 @@ fn help_response() -> TerminalCommandResponse { #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use futures::future::BoxFuture; @@ -236,11 +271,15 @@ mod tests { use crate::terminal::yahoo_finance::{ SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch, }; - use crate::terminal::{Company, ExecuteTerminalCommandRequest, PanelPayload, TerminalCommandResponse}; + use crate::terminal::{ + Company, ExecuteTerminalCommandRequest, PanelPayload, TerminalCommandResponse, + }; struct FakeSecurityLookup { search_result: Result, SecurityLookupError>, fail_detail: bool, + search_calls: AtomicUsize, + detail_calls: AtomicUsize, } impl FakeSecurityLookup { @@ -248,6 +287,8 @@ mod tests { Self { search_result: Ok(matches), fail_detail: false, + search_calls: AtomicUsize::new(0), + detail_calls: AtomicUsize::new(0), } } } @@ -257,6 +298,7 @@ mod tests { &'a self, _query: &'a str, ) -> BoxFuture<'a, Result, SecurityLookupError>> { + self.search_calls.fetch_add(1, Ordering::Relaxed); Box::pin(async move { self.search_result.clone() }) } @@ -264,6 +306,7 @@ mod tests { &'a self, security_match: &'a SecurityMatch, ) -> BoxFuture<'a, Result> { + self.detail_calls.fetch_add(1, Ordering::Relaxed); Box::pin(async move { if self.fail_detail { return Err(SecurityLookupError::DetailUnavailable { @@ -273,7 +316,10 @@ mod tests { Ok(Company { symbol: security_match.symbol.clone(), - name: security_match.exchange.clone().unwrap_or_else(|| "N/A".to_string()), + name: security_match + .exchange + .clone() + .unwrap_or_else(|| "N/A".to_string()), price: 100.0, change: 1.0, change_percent: 1.0, @@ -288,12 +334,32 @@ mod tests { } } - fn build_service(search_result: Result, SecurityLookupError>) -> TerminalCommandService { + fn build_service( + search_result: Result, SecurityLookupError>, + ) -> (TerminalCommandService, Arc) { + let lookup = Arc::new(FakeSecurityLookup { + search_result, + fail_detail: false, + search_calls: AtomicUsize::new(0), + detail_calls: AtomicUsize::new(0), + }); + + ( + TerminalCommandService::with_dependencies(load_mock_financial_data(), lookup.clone()), + lookup, + ) + } + + fn build_failing_service( + search_result: Result, SecurityLookupError>, + ) -> TerminalCommandService { TerminalCommandService::with_dependencies( load_mock_financial_data(), Arc::new(FakeSecurityLookup { search_result, - fail_detail: false, + fail_detail: true, + search_calls: AtomicUsize::new(0), + detail_calls: AtomicUsize::new(0), }), ) } @@ -307,7 +373,7 @@ mod tests { #[test] fn returns_company_panel_for_exact_search_match() { - let service = build_service(Ok(vec![SecurityMatch { + let (service, lookup) = build_service(Ok(vec![SecurityMatch { symbol: "AAPL".to_string(), name: Some("Apple Inc.".to_string()), exchange: Some("NASDAQ".to_string()), @@ -322,11 +388,14 @@ mod tests { } => assert_eq!(data.symbol, "AAPL"), other => panic!("expected company panel, got {other:?}"), } + + assert_eq!(lookup.search_calls.load(Ordering::Relaxed), 0); + assert_eq!(lookup.detail_calls.load(Ordering::Relaxed), 1); } #[test] fn returns_text_list_for_name_search() { - let service = build_service(Ok(vec![ + let (service, lookup) = build_service(Ok(vec![ SecurityMatch { symbol: "AAPL".to_string(), name: Some("Apple Inc.".to_string()), @@ -352,11 +421,14 @@ mod tests { } other => panic!("expected text response, got {other:?}"), } + + assert_eq!(lookup.search_calls.load(Ordering::Relaxed), 1); + assert_eq!(lookup.detail_calls.load(Ordering::Relaxed), 0); } #[test] fn filters_unsupported_assets() { - let service = build_service(Ok(vec![SecurityMatch { + let (service, _) = build_service(Ok(vec![SecurityMatch { symbol: "BTC-USD".to_string(), name: Some("Bitcoin USD".to_string()), exchange: None, @@ -375,7 +447,7 @@ mod tests { #[test] fn prefers_us_exchange_priority_for_exact_matches() { - let service = build_service(Ok(vec![ + let (service, _) = build_service(Ok(vec![ SecurityMatch { symbol: "ABC".to_string(), name: Some("ABC Ltd".to_string()), @@ -390,7 +462,7 @@ mod tests { }, ])); - let response = execute(&service, "/search ABC"); + let response = execute(&service, "/search abc"); match response { TerminalCommandResponse::Panel { @@ -402,13 +474,13 @@ mod tests { #[test] fn returns_live_search_error_when_provider_search_fails() { - let service = build_service(Err(SecurityLookupError::SearchUnavailable)); + let (service, _) = build_service(Err(SecurityLookupError::SearchUnavailable)); - let response = execute(&service, "/search AAPL"); + let response = execute(&service, "/search apple"); match response { TerminalCommandResponse::Text { content } => { - assert_eq!(content, "Live search failed for \"AAPL\"."); + assert_eq!(content, "Live search failed for \"apple\"."); } other => panic!("expected text response, got {other:?}"), } @@ -416,18 +488,12 @@ mod tests { #[test] fn returns_live_detail_error_when_exact_resolution_fails() { - let service = TerminalCommandService::with_dependencies( - load_mock_financial_data(), - Arc::new(FakeSecurityLookup { - search_result: Ok(vec![SecurityMatch { - symbol: "AAPL".to_string(), - name: Some("Apple Inc.".to_string()), - exchange: Some("NASDAQ".to_string()), - kind: SecurityKind::Equity, - }]), - fail_detail: true, - }), - ); + let service = build_failing_service(Ok(vec![SecurityMatch { + symbol: "AAPL".to_string(), + name: Some("Apple Inc.".to_string()), + exchange: Some("NASDAQ".to_string()), + kind: SecurityKind::Equity, + }])); let response = execute(&service, "/search AAPL"); @@ -456,6 +522,23 @@ mod tests { } } + #[test] + fn exact_symbol_search_skips_live_search_lookup() { + let (service, lookup) = build_service(Ok(vec![])); + + let response = execute(&service, "/search CASY"); + + match response { + TerminalCommandResponse::Panel { + panel: PanelPayload::Company { data }, + } => assert_eq!(data.symbol, "CASY"), + other => panic!("expected company panel, got {other:?}"), + } + + assert_eq!(lookup.search_calls.load(Ordering::Relaxed), 0); + assert_eq!(lookup.detail_calls.load(Ordering::Relaxed), 1); + } + #[test] fn returns_text_for_unknown_command() { let service = TerminalCommandService::with_dependencies( diff --git a/MosaicIQ/src-tauri/src/terminal/yahoo_finance.rs b/MosaicIQ/src-tauri/src/terminal/yahoo_finance.rs index a3b3dc1..16bf77d 100644 --- a/MosaicIQ/src-tauri/src/terminal/yahoo_finance.rs +++ b/MosaicIQ/src-tauri/src/terminal/yahoo_finance.rs @@ -115,11 +115,7 @@ impl SecurityLookup for YahooFinanceLookup { symbol: security_match.symbol.clone(), }; - let quote = self - .fetch_live_quote(&security_match.symbol) - .await - .map_err(|_| detail_error())?; - + let quote = self.fetch_live_quote(&security_match.symbol).await?; map_live_quote_to_company(security_match, quote).ok_or_else(detail_error) }) } @@ -161,6 +157,7 @@ impl YahooFinanceLookup { } } +// Map the full quote payload directly so an exact-symbol lookup only needs one request. fn map_live_quote_to_company( security_match: &SecurityMatch, quote: YahooQuoteResult, @@ -229,7 +226,7 @@ mod tests { use super::{map_live_quote_to_company, SecurityKind, SecurityMatch, YahooQuoteResult}; #[test] - fn maps_live_quote_fields_into_company_panel_shape() { + fn maps_company_panel_shape_from_single_live_quote_response() { let security_match = SecurityMatch { symbol: "CASEY".to_string(), name: Some("Fallback Name".to_string()), @@ -253,7 +250,7 @@ mod tests { fifty_two_week_low: Some(313.89), }, ) - .unwrap(); + .expect("quote should map into a company"); assert_eq!(company.symbol, "CASEY"); assert_eq!(company.name, "Casey's General Stores, Inc."); @@ -293,7 +290,7 @@ mod tests { fifty_two_week_low: None, }, ) - .unwrap(); + .expect("quote should map into a company"); assert_eq!(company.name, "Fallback Name"); assert_eq!(company.change, 0.0);