Skip to main content

tvscreener/tui/
scan.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared scan configuration, payload build, and codegen for the TUI.
5
6use serde_json::Value;
7
8use crate::core::{with_asset_screener, Screener};
9use crate::field::{list_presets, Asset};
10use crate::query_config::{apply_filters, apply_sort};
11use crate::resolve::apply_stock_index_markets;
12use crate::Result;
13
14use super::model::ScanConfig;
15
16/// Preset names for an asset (`stock_*`, `crypto_*`, …) plus a defaults entry.
17#[must_use]
18pub fn preset_options(asset: Asset) -> Vec<String> {
19    let prefix = format!("{}_", asset.as_str());
20    let mut names: Vec<String> = list_presets()
21        .into_iter()
22        .filter(|name| name.starts_with(&prefix))
23        .collect();
24    names.sort();
25    names.insert(0, "(defaults)".into());
26    names
27}
28
29/// Index of `preset` in [`preset_options`] (`0` when unset or unknown).
30#[must_use]
31pub fn preset_index(asset: Asset, preset: Option<&str>) -> usize {
32    let options = preset_options(asset);
33    preset.map_or(0, |name| {
34        options.iter().position(|p| p == name).unwrap_or(0)
35    })
36}
37
38/// Sets `config.preset` from a preset picker index.
39pub fn apply_preset_index(config: &mut ScanConfig, index: usize) {
40    let options = preset_options(config.asset);
41    let picked = options.get(index).map(String::as_str);
42    config.preset = match picked {
43        None | Some("(defaults)") => None,
44        Some(name) => Some(name.to_string()),
45    };
46}
47
48/// Configures a screener from TUI scan parameters.
49///
50/// # Errors
51///
52/// Propagates field resolution, filter, search, and stock market errors.
53pub fn configure_screener(inner: &mut Screener, config: &ScanConfig, debug: bool) -> Result<()> {
54    let fields = config.resolve_fields()?;
55    if debug || crate::logging::env_debug_enabled() {
56        inner.set_debug(true);
57    }
58    inner
59        .select(fields)
60        .set_range(config.from, config.from.saturating_add(config.limit));
61    if let Some(q) = config.search.as_deref().filter(|s| !s.is_empty()) {
62        inner.search(q)?;
63    }
64    apply_filters(inner, config.asset, &config.filters)?;
65    apply_sort(
66        inner,
67        config.asset,
68        config.sort_by.as_deref(),
69        config.ascending,
70    )?;
71    if matches!(config.asset, Asset::Stock) {
72        apply_stock_index_markets(inner, config.index.as_deref(), config.markets.as_deref())?;
73    } else if config.markets.is_some() || config.index.is_some() {
74        return Err(crate::TvscreenerError::InvalidRequest(
75            "--markets / --index only apply to stock".into(),
76        ));
77    }
78    Ok(())
79}
80
81/// Builds the scanner POST JSON for the current config (no network).
82///
83/// # Errors
84///
85/// Propagates [`configure_screener`] and [`Screener::build_payload`] errors.
86pub fn build_payload(config: &ScanConfig, debug: bool) -> Result<Value> {
87    with_asset_screener(config.asset, |inner| {
88        configure_screener(inner, config, debug)?;
89        inner.build_payload()
90    })
91}
92
93/// Pretty JSON for the payload pane.
94///
95/// # Errors
96///
97/// Propagates [`build_payload`] or JSON serialization errors.
98pub fn payload_pretty(config: &ScanConfig, debug: bool) -> Result<String> {
99    let value = build_payload(config, debug)?;
100    serde_json::to_string_pretty(&value).map_err(Into::into)
101}
102
103/// Rust + CLI snippets for the codegen pane.
104#[must_use]
105pub fn render_codegen(config: &ScanConfig) -> String {
106    let mut out = String::new();
107    out.push_str("// Rust\n");
108    out.push_str(&render_rust_snippet(config));
109    out.push_str("\n\n# CLI\n");
110    out.push_str(&render_cli_command(config));
111    out
112}
113
114const fn typed_screener_name(asset: Asset) -> &'static str {
115    match asset {
116        Asset::Stock => "StockScreener",
117        Asset::Crypto => "CryptoScreener",
118        Asset::Forex => "ForexScreener",
119        Asset::Bond => "BondScreener",
120        Asset::Futures => "FuturesScreener",
121        Asset::Coin => "CoinScreener",
122    }
123}
124
125fn render_rust_snippet(config: &ScanConfig) -> String {
126    let screener = typed_screener_name(config.asset);
127    let mut lines = vec![
128        "use serde_json::json;".to_string(),
129        format!("use tvscreener::core::{screener};"),
130        "use tvscreener::filter::{FieldCondition, FilterOperator};".to_string(),
131    ];
132    let needs_preset = config.preset.is_some();
133    let needs_resolve = config
134        .sort_by
135        .as_deref()
136        .is_some_and(|sort| !sort.is_empty());
137    match (needs_preset, needs_resolve) {
138        (true, true) => {
139            lines.push("use tvscreener::field::{get_preset, resolve_field};".to_string());
140        }
141        (true, false) => lines.push("use tvscreener::field::get_preset;".to_string()),
142        (false, true) => lines.push("use tvscreener::field::resolve_field;".to_string()),
143        (false, false) => {}
144    }
145    if matches!(config.asset, Asset::Stock) && (config.index.is_some() || config.markets.is_some())
146    {
147        lines.push("use tvscreener::resolve::apply_stock_index_markets;".to_string());
148    }
149    lines.push(String::new());
150    lines.push(format!("let mut screener = {screener}::new();"));
151    if let Some(preset) = config.preset.as_deref() {
152        lines.push(format!("let fields = get_preset({preset:?})?;"));
153        lines.push("screener.select(fields);".to_string());
154    }
155    lines.push(format!(
156        "screener.set_range({}, {});",
157        config.from,
158        config.from.saturating_add(config.limit)
159    ));
160    if let Some(q) = config.search.as_deref().filter(|s| !s.is_empty()) {
161        lines.push(format!("screener.search({q:?})?;"));
162    }
163    for f in &config.filters {
164        lines.push(format!(
165            "screener.where_condition(FieldCondition::new({:?}, FilterOperator::{:?}, json!({})))?;",
166            f.left,
167            f.operation,
168            filter_value_literal(&f.value)
169        ));
170    }
171    if let Some(sort) = config.sort_by.as_deref().filter(|s| !s.is_empty()) {
172        lines.push(format!(
173            "let (_, sort_field) = resolve_field({}, {sort:?}).expect(\"sort field\");",
174            asset_type_expr(config.asset)
175        ));
176        lines.push(format!(
177            "screener.inner_mut().sort_by_field(&sort_field, {});",
178            config.ascending
179        ));
180    }
181    if matches!(config.asset, Asset::Stock) {
182        if let Some(index) = config.index.as_deref() {
183            lines.push(format!(
184                "apply_stock_index_markets(screener.inner_mut(), Some({index:?}), None)?;"
185            ));
186        }
187        if let Some(markets) = config.markets.as_deref() {
188            let index_arg = config
189                .index
190                .as_deref()
191                .map_or_else(|| "None".into(), |i| format!("Some({i:?})"));
192            lines.push(format!(
193                "apply_stock_index_markets(screener.inner_mut(), {index_arg}, Some({markets:?}))?;"
194            ));
195        }
196    }
197    lines.push("let rows = screener.get().await?;".to_string());
198    lines.join("\n")
199}
200
201fn filter_value_literal(value: &Value) -> String {
202    match value {
203        Value::String(s) => format!("{s:?}"),
204        other => other.to_string(),
205    }
206}
207
208fn filter_token(condition: &crate::filter::FieldCondition) -> String {
209    format!(
210        "{}:{}:{}",
211        condition.left,
212        condition.operation.as_str(),
213        filter_value_cli_token(&condition.value)
214    )
215}
216
217fn filter_value_cli_token(value: &Value) -> String {
218    match value {
219        Value::Number(n) => n.to_string(),
220        Value::String(s) => s.clone(),
221        Value::Bool(b) => b.to_string(),
222        Value::Null => String::new(),
223        other => other.to_string(),
224    }
225}
226
227const fn asset_type_expr(asset: Asset) -> &'static str {
228    match asset {
229        Asset::Stock => "tvscreener::field::Asset::Stock",
230        Asset::Crypto => "tvscreener::field::Asset::Crypto",
231        Asset::Forex => "tvscreener::field::Asset::Forex",
232        Asset::Bond => "tvscreener::field::Asset::Bond",
233        Asset::Futures => "tvscreener::field::Asset::Futures",
234        Asset::Coin => "tvscreener::field::Asset::Coin",
235    }
236}
237
238fn append_cli_flags(parts: &mut Vec<String>, config: &ScanConfig) {
239    if let Some(preset) = &config.preset {
240        parts.push(format!("--preset {preset}"));
241    }
242    if config.from != 0 {
243        parts.push(format!("--from {}", config.from));
244    }
245    if config.limit != 25 {
246        parts.push(format!("--limit {}", config.limit));
247    }
248    if let Some(q) = config.search.as_deref().filter(|s| !s.is_empty()) {
249        parts.push(format!("--search {q:?}"));
250    }
251    for filter in &config.filters {
252        parts.push(format!("--filter {}", filter_token(filter)));
253    }
254    if let Some(sort) = config.sort_by.as_deref().filter(|s| !s.is_empty()) {
255        parts.push(format!("--sort-by {sort}"));
256    }
257    if config.ascending {
258        parts.push("--ascending".into());
259    }
260    if let Some(markets) = &config.markets {
261        parts.push(format!("--markets {markets}"));
262    }
263    if let Some(index) = &config.index {
264        parts.push(format!("--index {index}"));
265    }
266}
267
268fn render_cli_command(config: &ScanConfig) -> String {
269    let mut tui = vec![
270        "tvscreener-tui".to_string(),
271        config.asset.as_str().to_string(),
272    ];
273    append_cli_flags(&mut tui, config);
274    let mut scan = vec![
275        "tvscreener".to_string(),
276        "scan".to_string(),
277        config.asset.as_str().to_string(),
278    ];
279    append_cli_flags(&mut scan, config);
280    format!("{}\n{}", tui.join(" "), scan.join(" "))
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::field::{default_fields, get_preset};
287    use crate::filter::{FieldCondition, FilterOperator};
288    use serde_json::json;
289
290    fn sample_config() -> ScanConfig {
291        ScanConfig {
292            asset: Asset::Crypto,
293            preset: Some("crypto_price".into()),
294            from: 0,
295            limit: 10,
296            search: Some("BTC".into()),
297            markets: None,
298            index: None,
299            sort_by: None,
300            ascending: false,
301            filters: vec![FieldCondition::new(
302                "close",
303                FilterOperator::Above,
304                json!(50_000),
305            )],
306        }
307    }
308
309    #[test]
310    fn preset_options_start_with_defaults() {
311        let opts = preset_options(Asset::Crypto);
312        assert_eq!(opts[0], "(defaults)");
313        assert!(opts.iter().any(|n| n == "crypto_price"));
314    }
315
316    #[test]
317    fn build_payload_includes_filter() {
318        let config = sample_config();
319        let payload = build_payload(&config, false).expect("payload");
320        let filters = payload["filter"].as_array().expect("filter array");
321        assert!(filters.iter().any(|f| f["left"] == "close"));
322    }
323
324    #[test]
325    fn codegen_mentions_typed_screener_and_cli() {
326        let text = render_codegen(&sample_config());
327        assert!(text.contains("CryptoScreener"));
328        assert!(text.contains("tvscreener-tui"));
329        assert!(text.contains("tvscreener scan"));
330        assert!(text.contains("where_condition"));
331        assert!(text.contains("--filter close:greater:50000"));
332    }
333
334    #[test]
335    fn codegen_emits_sort_flags_and_rust_sort() {
336        let mut config = sample_config();
337        config.sort_by = Some("volume".into());
338        config.ascending = true;
339        let text = render_codegen(&config);
340        assert!(text.contains("--sort-by volume"));
341        assert!(text.contains("--ascending"));
342        assert!(text.contains("sort_by_field"));
343        assert!(text.contains("resolve_field"));
344    }
345
346    #[test]
347    fn build_payload_includes_sort() {
348        let mut config = sample_config();
349        config.sort_by = Some("volume".into());
350        config.ascending = true;
351        let payload = build_payload(&config, false).expect("payload");
352        assert_eq!(payload["sort"]["sortBy"], "volume");
353        assert_eq!(payload["sort"]["sortOrder"], "asc");
354    }
355
356    #[test]
357    fn filter_token_uses_wire_operator() {
358        let token = filter_token(&FieldCondition::new(
359            "close",
360            FilterOperator::Above,
361            json!(100),
362        ));
363        assert_eq!(token, "close:greater:100");
364    }
365
366    #[test]
367    fn apply_preset_index_clears_on_defaults() {
368        let mut config = sample_config();
369        apply_preset_index(&mut config, 0);
370        assert!(config.preset.is_none());
371    }
372
373    #[test]
374    fn resolve_fields_uses_preset() {
375        let fields = sample_config().resolve_fields().expect("fields");
376        assert_ne!(fields.len(), 0);
377        let preset_only = get_preset("crypto_price").expect("preset");
378        assert_eq!(fields.len(), preset_only.len());
379        assert_ne!(fields, default_fields(Asset::Crypto));
380    }
381}