Skip to main content

tvscreener/mcp/
tools.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Offline-friendly helpers shared by MCP tools (no rmcp dependency).
5
6pub use super::format::{
7    format_discover_fields, format_field_types, format_get_preset, format_list_countries,
8    format_list_exchanges, format_list_filter_operators, format_list_index_symbols,
9    format_list_industries, format_list_markets, format_list_presets, format_list_ratings,
10    format_list_sectors, format_rows_markdown,
11};
12pub use super::query::{
13    build_payload, custom_query, custom_query_payload_preview, get_top_movers, search_by_index,
14    search_crypto, search_forex, search_stocks, BuildPayloadOpts, CustomQueryOpts,
15    PayloadPreviewOpts, SearchStocksOpts,
16};
17pub use super::resolve::{
18    parse_asset, parse_f64_range, resolve_index_wires, resolve_market_wires, resolve_sector_wires,
19};
20
21#[cfg(test)]
22mod tests {
23    use super::*;
24    use crate::field::Asset;
25    use crate::filter::FilterOperator;
26
27    #[test]
28    fn parse_ops_and_asset() {
29        assert_eq!(
30            FilterOperator::from_wire(">="),
31            Some(FilterOperator::AboveOrEqual)
32        );
33        assert_eq!(
34            FilterOperator::from_wire("in_range"),
35            Some(FilterOperator::InRange)
36        );
37        assert_eq!(parse_asset("crypto").unwrap(), Asset::Crypto);
38        assert!(parse_asset("nope").is_err());
39    }
40
41    #[test]
42    fn discover_and_presets_offline() {
43        let text = format_discover_fields(Asset::Crypto, "rsi", 5);
44        assert!(text.contains("RSI") || text.contains("fields matching"));
45        assert!(format_list_presets().contains("crypto_price"));
46        assert!(format_get_preset("crypto_price").unwrap().contains("Name"));
47        assert!(format_field_types(Asset::Stock).contains("**price**"));
48        assert!(format_list_sectors().contains("TECHNOLOGY_SERVICES"));
49        assert!(format_list_sectors().contains("Technology Services"));
50        assert!(format_list_countries().contains("UNITED_STATES"));
51        assert!(format_list_industries().contains("SEMICONDUCTORS"));
52        assert!(format_list_exchanges().contains("NASDAQ"));
53        assert!(format_list_ratings().contains("STRONG_BUY"));
54        assert!(format_list_filter_operators().contains("in_range"));
55        assert!(format_list_filter_operators().contains("PRICE"));
56        assert!(format_list_filter_operators().contains("Technology Services"));
57        assert!(
58            format_list_markets().contains("AMERICA") || format_list_markets().contains("america")
59        );
60        assert!(format_list_index_symbols().contains("SP500"));
61        assert!(format_list_index_symbols().contains("SP;SPX"));
62    }
63
64    #[test]
65    fn custom_payload_preview_merges_filters() {
66        let payload = custom_query_payload_preview(&PayloadPreviewOpts {
67            asset_type: "stock",
68            fields: Some("NAME,PRICE"),
69            filters: Some(
70                r#"[{"field":"PRICE","op":">","value":50},{"field":"RSI","op":"in_range","value":[30,70]}]"#,
71            ),
72            sort_by: Some("PRICE"),
73            ascending: false,
74            limit: 10,
75            indices: None,
76            markets: None,
77        })
78        .unwrap();
79        assert!(payload["columns"].as_array().unwrap().len() >= 2);
80        assert_eq!(payload["range"], serde_json::json!([0, 10]));
81        let filters = payload["filter"].as_array().unwrap();
82        assert!(filters
83            .iter()
84            .any(|f| f["left"] == "close" || f["operation"] == "greater"));
85    }
86
87    #[test]
88    fn build_payload_with_index_and_markets() {
89        let text = build_payload(&BuildPayloadOpts {
90            asset_type: "stock",
91            fields: Some("NAME,PRICE"),
92            filters: None,
93            sort_by: Some("MARKET_CAPITALIZATION"),
94            indices: Some("SP500,NASDAQ_100"),
95            markets: Some("AMERICA"),
96        })
97        .unwrap();
98        assert!(text.contains("SYML:SP;SPX"));
99        assert!(text.contains("SYML:NASDAQ;NDX"));
100        assert!(text.contains("america"));
101        assert_eq!(resolve_index_wires(Some("SP;SPX")).unwrap(), vec!["SP;SPX"]);
102        assert_eq!(
103            resolve_index_wires(Some("SYML:SP;SPX")).unwrap(),
104            vec!["SP;SPX"]
105        );
106        assert_eq!(resolve_index_wires(Some("SP500")).unwrap(), vec!["SP;SPX"]);
107        assert_eq!(
108            resolve_market_wires(Some("america")).unwrap(),
109            vec!["america"]
110        );
111        assert!(resolve_index_wires(Some("NOPE")).is_err());
112    }
113
114    #[test]
115    fn payload_preview_indices_emit_syml_symbolset() {
116        let payload = custom_query_payload_preview(&PayloadPreviewOpts {
117            asset_type: "stock",
118            fields: Some("NAME,PRICE"),
119            filters: None,
120            sort_by: None,
121            ascending: false,
122            limit: 5,
123            indices: Some("SP500"),
124            markets: Some("AMERICA"),
125        })
126        .unwrap();
127        assert_eq!(
128            payload["symbols"]["symbolset"],
129            serde_json::json!(["SYML:SP;SPX"])
130        );
131        assert_eq!(payload["markets"], serde_json::json!(["america"]));
132    }
133
134    #[test]
135    fn format_rows_markdown_empty() {
136        assert!(format_rows_markdown(&[], 5).contains("No results"));
137    }
138}