From cd4d8d8323d93db9ee65dd7544c64cb7cf892be8 Mon Sep 17 00:00:00 2001 From: Ciphercat <78522797+0xrsydn@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:48:30 +0000 Subject: [PATCH] fix(api): reject empty ticker in resolve_symbol() - Change resolve_symbol return type from String to Result - Add early validation for empty/whitespace-only ticker input - Return Err(IdxError::InvalidInput) for empty tickers - Add InvalidInput variant to IdxError enum - Update all 15 callers in cli/stocks.rs to handle Result with ? propagation - Update tests: empty/whitespace tickers now return error Fixes #7 --- src/api/mod.rs | 28 +++++++++++++++++++--------- src/cli/stocks.rs | 28 ++++++++++++++-------------- src/error.rs | 4 ++++ 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index fceac7a..3cd552c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -62,15 +62,20 @@ pub trait NewsProvider { fn news(&self, symbol: &str, limit: usize) -> Result, IdxError>; } -pub fn resolve_symbol(symbol: &str, exchange: &str) -> String { +pub fn resolve_symbol(symbol: &str, exchange: &str) -> Result { let trimmed = symbol.trim().to_uppercase(); + if trimmed.is_empty() { + return Err(IdxError::InvalidInput( + "ticker symbol cannot be empty".into(), + )); + } if let Some((base, suffix)) = trimmed.rsplit_once('.') && !base.is_empty() && !suffix.is_empty() { - return trimmed; + return Ok(trimmed); } - format!("{trimmed}.{}", exchange.trim().to_uppercase()) + Ok(format!("{trimmed}.{}", exchange.trim().to_uppercase())) } pub fn default_provider(provider: ProviderKind, verbose: bool) -> Box { @@ -224,11 +229,16 @@ mod tests { #[test] fn resolves_symbol_variants() { - assert_eq!(resolve_symbol("bbca", "JK"), "BBCA.JK"); - assert_eq!(resolve_symbol("BBCA.JK", "JK"), "BBCA.JK"); - assert_eq!(resolve_symbol("TLKM.us", "JK"), "TLKM.US"); - assert_eq!(resolve_symbol("abcd.ef.gh", "JK"), "ABCD.EF.GH"); - assert_eq!(resolve_symbol(" bbri ", "jk"), "BBRI.JK"); - assert_eq!(resolve_symbol("", "JK"), ".JK"); + assert_eq!(resolve_symbol("bbca", "JK").unwrap(), "BBCA.JK"); + assert_eq!(resolve_symbol("BBCA.JK", "JK").unwrap(), "BBCA.JK"); + assert_eq!(resolve_symbol("TLKM.us", "JK").unwrap(), "TLKM.US"); + assert_eq!(resolve_symbol("abcd.ef.gh", "JK").unwrap(), "ABCD.EF.GH"); + assert_eq!(resolve_symbol(" bbri ", "jk").unwrap(), "BBRI.JK"); + // Empty ticker should return error + assert!(resolve_symbol("", "JK").is_err()); + // Whitespace-only ticker should also return error + assert!(resolve_symbol(" ", "JK").is_err()); + // Valid ticker returns Ok + assert_eq!(resolve_symbol("BBCA", "JK").unwrap(), "BBCA.JK"); } } diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 1240ec2..2b69269 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -171,7 +171,7 @@ pub fn handle( let quote_bucket = cache_bucket(config, "quote"); let mut quotes = Vec::new(); for sym in symbols.iter().flat_map(|s| s.split(',')) { - let resolved = crate::api::resolve_symbol(sym, &config.exchange); + let resolved = crate::api::resolve_symbol(sym, &config.exchange)?; if !no_cache && let Some(q) = cache.get("e_bucket, &resolved)? { quotes.push(q); continue; @@ -227,7 +227,7 @@ pub fn handle( ); } let history_bucket = format!("{}-history", history_source.as_str()); - let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let key = format!("{}-{}", period.as_str(), interval.as_str()); if !no_cache && let Some(history) = cache.get::>( @@ -293,7 +293,7 @@ pub fn handle( ); } let technical_bucket = format!("{}-technical", history_source.as_str()); - let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; if !no_cache && let Some(report) = cache.get::(&technical_bucket, &resolved)? { @@ -327,7 +327,7 @@ pub fn handle( } } StocksSubcommand::Growth { symbol } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let report: GrowthReport = fetch_fundamental_analysis_report( &cache, provider, @@ -343,7 +343,7 @@ pub fn handle( render_growth(&resolved, &report, &config.output, config.no_color) } StocksSubcommand::Valuation { symbol } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let report: ValuationReport = fetch_fundamental_analysis_report( &cache, provider, @@ -359,7 +359,7 @@ pub fn handle( render_valuation(&resolved, &report, &config.output, config.no_color) } StocksSubcommand::Risk { symbol } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let report: RiskReport = fetch_fundamental_analysis_report( &cache, provider, @@ -375,7 +375,7 @@ pub fn handle( render_risk(&resolved, &report, &config.output, config.no_color) } StocksSubcommand::Fundamental { symbol } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let report: FundamentalReport = fetch_fundamental_analysis_report( &cache, provider, @@ -391,7 +391,7 @@ pub fn handle( render_fundamental(&report, &config.output, config.no_color) } StocksSubcommand::Profile { symbol } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let profile: CompanyProfile = fetch_msn_only(&resolved, config.provider, || { MsnProvider::new(false).profile(&resolved) })?; @@ -401,7 +401,7 @@ pub fn handle( symbol, statement: _, } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let financials: FinancialStatements = fetch_msn_only(&resolved, config.provider, || { MsnProvider::new(false).financials(&resolved) @@ -415,28 +415,28 @@ pub fn handle( forecast: _, history: _, } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let earnings: EarningsReport = fetch_msn_only(&resolved, config.provider, || { MsnProvider::new(false).earnings(&resolved) })?; render_earnings(&earnings, &config.output) } StocksSubcommand::Sentiment { symbol } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let sentiment: SentimentData = fetch_msn_only(&resolved, config.provider, || { MsnProvider::new(false).sentiment(&resolved) })?; render_sentiment(&sentiment, &config.output) } StocksSubcommand::Insights { symbol } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let insights: InsightData = fetch_msn_only(&resolved, config.provider, || { MsnProvider::new(false).insights(&resolved) })?; render_insights(&insights, &config.output) } StocksSubcommand::News { symbol, limit } => { - let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let news: Vec = fetch_msn_only(&resolved, config.provider, || { MsnProvider::new(false).news(&resolved, *limit) })?; @@ -460,7 +460,7 @@ pub fn handle( let mut last_error = None; for sym in symbols.iter().flat_map(|s| s.split(',')) { - let resolved = crate::api::resolve_symbol(sym, &config.exchange); + let resolved = crate::api::resolve_symbol(sym, &config.exchange)?; match fetch_fundamental_analysis_report( &cache, provider, diff --git a/src/error.rs b/src/error.rs index 5f882ed..33dbf53 100644 --- a/src/error.rs +++ b/src/error.rs @@ -30,6 +30,8 @@ pub enum IdxError { DatabaseError(String), #[error("PDF parse error: {0}")] PdfParseError(String), + #[error("invalid input: {0}")] + InvalidInput(String), } #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] @@ -47,6 +49,7 @@ pub enum ErrorCode { AuthError, DatabaseError, PdfParseError, + InvalidInput, } impl IdxError { @@ -65,6 +68,7 @@ impl IdxError { Self::AuthError(_) => ErrorCode::AuthError, Self::DatabaseError(_) => ErrorCode::DatabaseError, Self::PdfParseError(_) => ErrorCode::PdfParseError, + Self::InvalidInput(_) => ErrorCode::InvalidInput, } }