1use 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#[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#[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
38pub 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
48pub 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
81pub 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
93pub 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#[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}