Skip to main content

tvscreener/
resolve.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared CSV token parsing and catalog wire-value resolution (CLI + MCP).
5
6use crate::error::{Result, TvscreenerError};
7use crate::field::{
8    all_index_symbols, all_markets, index_symbol_value, market, resolve_country, resolve_exchange,
9    resolve_industry, resolve_sector,
10};
11
12/// Splits an optional comma-separated string into trimmed non-empty tokens.
13#[must_use]
14pub fn parse_csv_tokens(raw: Option<&str>) -> Vec<String> {
15    raw.map(|s| {
16        s.split(',')
17            .map(str::trim)
18            .filter(|p| !p.is_empty())
19            .map(str::to_string)
20            .collect()
21    })
22    .unwrap_or_default()
23}
24
25/// Resolves index CSV tokens (const name or wire) to symbolset wire values.
26///
27/// # Errors
28///
29/// Returns [`TvscreenerError::InvalidRequest`] for unknown tokens.
30pub fn resolve_index_wires(indices: Option<&str>) -> Result<Vec<String>> {
31    resolve_token_csv(indices, "index", |token| {
32        let catalog = token.strip_prefix("SYML:").unwrap_or(token);
33        let upper = catalog.to_ascii_uppercase().replace(' ', "_");
34        if let Some(wire) = index_symbol_value(&upper) {
35            return Some(wire.to_string());
36        }
37        all_index_symbols()
38            .iter()
39            .find(|idx| idx.value.eq_ignore_ascii_case(catalog))
40            .map(|idx| idx.value.clone())
41    })
42}
43
44/// Resolves market CSV tokens (const name or wire) to market wire values.
45///
46/// # Errors
47///
48/// Returns [`TvscreenerError::InvalidRequest`] for unknown tokens.
49pub fn resolve_market_wires(markets: Option<&str>) -> Result<Vec<String>> {
50    resolve_token_csv(markets, "market", |token| {
51        let upper = token.to_ascii_uppercase().replace(' ', "_");
52        if let Some(m) = market(&upper) {
53            return Some(m.value);
54        }
55        all_markets()
56            .iter()
57            .find(|m| m.value.eq_ignore_ascii_case(token))
58            .map(|m| m.value.clone())
59    })
60}
61
62/// Resolves sector CSV tokens (const name or wire) to sector wire values.
63///
64/// # Errors
65///
66/// Returns [`TvscreenerError::InvalidRequest`] for unknown tokens.
67pub fn resolve_sector_wires(sectors: Option<&str>) -> Result<Vec<String>> {
68    resolve_named_csv(sectors, resolve_sector, "sector")
69}
70
71/// Resolves country CSV tokens (const name or wire) to country wire values.
72///
73/// # Errors
74///
75/// Returns [`TvscreenerError::InvalidRequest`] for unknown tokens.
76pub fn resolve_country_wires(countries: Option<&str>) -> Result<Vec<String>> {
77    resolve_named_csv(countries, resolve_country, "country")
78}
79
80/// Resolves industry CSV tokens (const name or wire) to industry wire values.
81///
82/// # Errors
83///
84/// Returns [`TvscreenerError::InvalidRequest`] for unknown tokens.
85pub fn resolve_industry_wires(industries: Option<&str>) -> Result<Vec<String>> {
86    resolve_named_csv(industries, resolve_industry, "industry")
87}
88
89/// Resolves exchange CSV tokens (const name or wire) to exchange wire values.
90///
91/// # Errors
92///
93/// Returns [`TvscreenerError::InvalidRequest`] for unknown tokens.
94pub fn resolve_exchange_wires(exchanges: Option<&str>) -> Result<Vec<String>> {
95    resolve_named_csv(exchanges, resolve_exchange, "exchange")
96}
97
98/// Applies resolved index symbolsets and market filters to a stock screener.
99///
100/// # Errors
101///
102/// Returns errors from [`resolve_index_wires`] or [`resolve_market_wires`].
103pub fn apply_stock_index_markets(
104    screener: &mut crate::core::Screener,
105    indices: Option<&str>,
106    markets: Option<&str>,
107) -> Result<()> {
108    let wires = resolve_index_wires(indices)?;
109    if !wires.is_empty() {
110        screener.set_index(wires);
111    }
112    let market_wires = resolve_market_wires(markets)?;
113    if !market_wires.is_empty() {
114        screener.set_markets(market_wires);
115    }
116    Ok(())
117}
118
119fn resolve_token_csv(
120    raw: Option<&str>,
121    kind: &str,
122    resolve_one: impl Fn(&str) -> Option<String>,
123) -> Result<Vec<String>> {
124    let mut out = Vec::new();
125    for token in parse_csv_tokens(raw) {
126        let Some(wire) = resolve_one(&token) else {
127            return Err(TvscreenerError::InvalidRequest(format!(
128                "unknown {kind} `{token}`"
129            )));
130        };
131        out.push(wire);
132    }
133    Ok(out)
134}
135
136fn resolve_named_csv(
137    raw: Option<&str>,
138    resolve: fn(&str) -> Option<&'static str>,
139    kind: &str,
140) -> Result<Vec<String>> {
141    resolve_token_csv(raw, kind, |token| resolve(token).map(str::to_string))
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn market_and_index_wires() {
150        assert_eq!(
151            resolve_market_wires(Some("AMERICA")).unwrap(),
152            vec!["america".to_string()]
153        );
154        assert_eq!(
155            resolve_index_wires(Some("SP500")).unwrap(),
156            vec!["SP;SPX".to_string()]
157        );
158        assert!(resolve_market_wires(Some("not-a-market")).is_err());
159    }
160
161    #[test]
162    fn named_catalog_wires() {
163        assert_eq!(
164            resolve_sector_wires(Some("TECHNOLOGY_SERVICES")).unwrap(),
165            vec!["Technology Services".to_string()]
166        );
167        assert_eq!(
168            resolve_country_wires(Some("UNITED_STATES")).unwrap(),
169            vec!["United States".to_string()]
170        );
171        assert_eq!(
172            resolve_industry_wires(Some("SEMICONDUCTORS")).unwrap(),
173            vec!["Semiconductors".to_string()]
174        );
175        assert_eq!(
176            resolve_exchange_wires(Some("NASDAQ")).unwrap(),
177            vec!["NASDAQ".to_string()]
178        );
179        assert!(resolve_sector_wires(Some("nope")).is_err());
180        assert!(resolve_country_wires(Some("nope")).is_err());
181    }
182
183    #[test]
184    fn parse_csv_and_apply_stock_wires() {
185        assert_eq!(
186            parse_csv_tokens(Some(" AMERICA , EUROPE ")),
187            vec!["AMERICA".to_string(), "EUROPE".to_string()]
188        );
189        let mut s = crate::core::Screener::new("global");
190        s.select([crate::field::FieldDef::new("Name", "name")]);
191        apply_stock_index_markets(&mut s, Some("SP500"), Some("AMERICA")).unwrap();
192        let payload = s.build_payload().unwrap();
193        assert_eq!(
194            payload["symbols"]["symbolset"],
195            serde_json::json!(["SYML:SP;SPX"])
196        );
197        assert_eq!(payload["markets"], serde_json::json!(["america"]));
198    }
199}