Skip to main content

tvscreener/
query_config.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared filter/sort parsing and application for CLI, TUI, and MCP.
5
6use crate::core::Screener;
7use crate::error::{Result, TvscreenerError};
8use crate::field::{resolve_field, Asset, FieldDef};
9use crate::filter::{FieldCondition, FilterOperator};
10use serde_json::{json, Value};
11
12/// Resolves a field const / technical / label name for `asset`.
13///
14/// # Errors
15///
16/// Returns [`TvscreenerError::InvalidRequest`] when the name is unknown for the asset.
17pub fn require_resolved(asset: Asset, name: &str) -> Result<FieldDef> {
18    resolve_field(asset, name)
19        .map(|(_, def)| def)
20        .ok_or_else(|| {
21            TvscreenerError::InvalidRequest(format!(
22                "unknown field `{name}` for asset `{}`",
23                asset.as_str()
24            ))
25        })
26}
27
28/// Parses a filters JSON string into a list of `{field, op, value}` objects.
29///
30/// Accepts a JSON array or a single object.
31///
32/// # Errors
33///
34/// Returns [`TvscreenerError::InvalidRequest`] when the JSON is invalid or the wrong shape.
35pub fn parse_filters_json(s: &str) -> Result<Vec<Value>> {
36    let raw = s.trim();
37    if raw.is_empty() {
38        return Ok(Vec::new());
39    }
40    let parsed: Value = serde_json::from_str(raw)
41        .map_err(|err| TvscreenerError::InvalidRequest(format!("filters JSON: {err}")))?;
42    match parsed {
43        Value::Array(items) => Ok(items),
44        Value::Object(_) => Ok(vec![parsed]),
45        other => Err(TvscreenerError::InvalidRequest(format!(
46            "filters must be a JSON array or object, got {other}"
47        ))),
48    }
49}
50
51/// Parses optional filters JSON (`None` / empty → empty list).
52///
53/// # Errors
54///
55/// Same as [`parse_filters_json`].
56pub fn parse_filters_arg(filters: Option<&str>) -> Result<Vec<Value>> {
57    let Some(raw) = filters.map(str::trim).filter(|s| !s.is_empty()) else {
58        return Ok(Vec::new());
59    };
60    parse_filters_json(raw)
61}
62
63/// Parses a CLI `FIELD:OP:VALUE` token into a [`FieldCondition`].
64///
65/// Splits on the first two `:` characters. Value is parsed as `f64`, then JSON, else a string.
66///
67/// # Errors
68///
69/// Returns [`TvscreenerError::InvalidRequest`] when the token shape or operator is invalid.
70pub fn parse_filter_token(s: &str) -> Result<FieldCondition> {
71    let trimmed = s.trim();
72    let (field, rest) = trimmed.split_once(':').ok_or_else(|| {
73        TvscreenerError::InvalidRequest(format!("filter token `{trimmed}` must be FIELD:OP:VALUE"))
74    })?;
75    let (op_str, value_raw) = rest.split_once(':').ok_or_else(|| {
76        TvscreenerError::InvalidRequest(format!("filter token `{trimmed}` must be FIELD:OP:VALUE"))
77    })?;
78    let field = field.trim();
79    let op_str = op_str.trim();
80    if field.is_empty() || op_str.is_empty() {
81        return Err(TvscreenerError::InvalidRequest(format!(
82            "filter token `{trimmed}` must be FIELD:OP:VALUE"
83        )));
84    }
85    let op = FilterOperator::from_wire(op_str)
86        .ok_or_else(|| TvscreenerError::InvalidRequest(format!("unknown filter op `{op_str}`")))?;
87    Ok(FieldCondition::new(
88        field,
89        op,
90        parse_filter_value(value_raw),
91    ))
92}
93
94/// Builds a [`FieldCondition`] from a JSON `{field, op, value}` object.
95///
96/// # Errors
97///
98/// Returns [`TvscreenerError::InvalidRequest`] for missing keys or unknown operators.
99pub fn filter_condition_from_json(entry: &Value) -> Result<FieldCondition> {
100    let field_name = entry
101        .get("field")
102        .and_then(Value::as_str)
103        .ok_or_else(|| TvscreenerError::InvalidRequest("filter missing field".into()))?;
104    let op_str = entry.get("op").and_then(Value::as_str).unwrap_or(">=");
105    let op = FilterOperator::from_wire(op_str)
106        .ok_or_else(|| TvscreenerError::InvalidRequest(format!("unknown filter op `{op_str}`")))?;
107    let value = entry
108        .get("value")
109        .cloned()
110        .ok_or_else(|| TvscreenerError::InvalidRequest("filter missing value".into()))?;
111    Ok(FieldCondition::new(field_name, op, value))
112}
113
114/// Parses repeatable `--filter` tokens plus optional `--filters` JSON.
115///
116/// # Errors
117///
118/// Propagates token, JSON, or operator parse failures.
119pub fn parse_filter_conditions_from_cli(
120    tokens: &[impl AsRef<str>],
121    filters_json: Option<&str>,
122) -> Result<Vec<FieldCondition>> {
123    let mut out = Vec::new();
124    for token in tokens {
125        out.push(parse_filter_token(token.as_ref())?);
126    }
127    for entry in parse_filters_arg(filters_json)? {
128        out.push(filter_condition_from_json(&entry)?);
129    }
130    Ok(out)
131}
132
133/// Parses a filter right-hand value: `f64`, then JSON, else a string.
134#[must_use]
135pub fn parse_filter_value(raw: &str) -> Value {
136    let trimmed = raw.trim();
137    if trimmed.is_empty() {
138        return Value::Null;
139    }
140    if let Ok(n) = trimmed.parse::<f64>() {
141        return json!(n);
142    }
143    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
144        return v;
145    }
146    json!(trimmed)
147}
148
149/// Applies JSON filter objects (`{field, op, value}`) to `screener`.
150///
151/// # Errors
152///
153/// Returns [`TvscreenerError::InvalidRequest`] for missing keys, unknown fields, or ops.
154pub fn apply_filters_json(screener: &mut Screener, asset: Asset, filters: &[Value]) -> Result<()> {
155    for entry in filters {
156        let condition = filter_condition_from_json(entry)?;
157        let def = require_resolved(asset, &condition.left)?;
158        screener.where_condition(FieldCondition::new(
159            def.field_name,
160            condition.operation,
161            condition.value,
162        ))?;
163    }
164    Ok(())
165}
166
167/// Applies resolved [`FieldCondition`]s (field left may be const / technical / label).
168///
169/// # Errors
170///
171/// Returns [`TvscreenerError::InvalidRequest`] when a field name cannot be resolved.
172pub fn apply_filters(
173    screener: &mut Screener,
174    asset: Asset,
175    conditions: &[FieldCondition],
176) -> Result<()> {
177    for condition in conditions {
178        let def = require_resolved(asset, &condition.left)?;
179        screener.where_condition(FieldCondition::new(
180            def.field_name,
181            condition.operation,
182            condition.value.clone(),
183        ))?;
184    }
185    Ok(())
186}
187
188/// Sets sort column when `sort_by` is present and non-empty.
189///
190/// # Errors
191///
192/// Returns [`TvscreenerError::InvalidRequest`] when the sort field is unknown.
193pub fn apply_sort(
194    screener: &mut Screener,
195    asset: Asset,
196    sort_by: Option<&str>,
197    ascending: bool,
198) -> Result<()> {
199    if let Some(name) = sort_by.map(str::trim).filter(|s| !s.is_empty()) {
200        let def = require_resolved(asset, name)?;
201        screener.sort_by_field(&def, ascending);
202    }
203    Ok(())
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::filter::FilterOperator;
210
211    #[test]
212    fn parse_filter_token_numeric_and_aliases() {
213        let c = parse_filter_token("close:greater:100").expect("token");
214        assert_eq!(c.left, "close");
215        assert_eq!(c.operation, FilterOperator::Above);
216        assert_eq!(c.value, json!(100.0));
217
218        let c = parse_filter_token("VOLUME:>=:1e6").expect("alias");
219        assert_eq!(c.left, "VOLUME");
220        assert_eq!(c.operation, FilterOperator::AboveOrEqual);
221        assert_eq!(c.value, json!(1e6));
222    }
223
224    #[test]
225    fn parse_filter_token_string_and_json_value() {
226        let c = parse_filter_token("name:match:btc").expect("string");
227        assert_eq!(c.value, json!("btc"));
228
229        let c = parse_filter_token(r#"name:match:"btc""#).expect("json string");
230        assert_eq!(c.value, json!("btc"));
231
232        let c = parse_filter_token("type:in_range:[\"stock\",\"fund\"]").expect("array");
233        assert!(c.value.is_array());
234    }
235
236    #[test]
237    fn parse_filter_token_rejects_bad_shape() {
238        assert!(parse_filter_token("close").is_err());
239        assert!(parse_filter_token("close:greater").is_err());
240        assert!(parse_filter_token(":greater:1").is_err());
241        assert!(parse_filter_token("close:nope:1").is_err());
242    }
243
244    #[test]
245    fn parse_filters_json_array_and_object() {
246        let list = parse_filters_json(r#"[{"field":"close","op":">","value":100}]"#).expect("arr");
247        assert_eq!(list.len(), 1);
248        assert_eq!(list[0]["field"], "close");
249
250        let list = parse_filters_json(r#"{"field":"close","op":">","value":100}"#).expect("obj");
251        assert_eq!(list.len(), 1);
252
253        assert_eq!(parse_filters_arg(None).unwrap().len(), 0);
254        assert_eq!(parse_filters_arg(Some("  ")).unwrap().len(), 0);
255        assert!(parse_filters_json("true").is_err());
256    }
257
258    #[test]
259    fn apply_sort_resolves_const_and_technical() {
260        let name = require_resolved(Asset::Stock, "NAME").expect("NAME");
261        let mut s = Screener::new("stock");
262        s.select([name.clone()]);
263        apply_sort(&mut s, Asset::Stock, Some("VOLUME"), false).expect("const");
264        let payload = s.build_payload().expect("payload");
265        assert_eq!(payload["sort"]["sortBy"], "volume");
266        assert_eq!(payload["sort"]["sortOrder"], "desc");
267
268        let mut s = Screener::new("stock");
269        s.select([name]);
270        apply_sort(&mut s, Asset::Stock, Some("volume"), true).expect("technical");
271        let payload = s.build_payload().expect("payload");
272        assert_eq!(payload["sort"]["sortBy"], "volume");
273        assert_eq!(payload["sort"]["sortOrder"], "asc");
274    }
275
276    #[test]
277    fn apply_filters_token_and_json() {
278        let name = require_resolved(Asset::Stock, "NAME").expect("NAME");
279        let mut s = Screener::new("stock");
280        s.select([name.clone()]);
281        let cond = parse_filter_token("close:greater:100").expect("token");
282        apply_filters(&mut s, Asset::Stock, &[cond]).expect("apply");
283        let payload = s.build_payload().expect("payload");
284        let filters = payload["filter"].as_array().expect("filters");
285        assert!(filters
286            .iter()
287            .any(|f| f["left"] == "close" && f["operation"] == "greater"));
288
289        let mut s = Screener::new("stock");
290        s.select([name]);
291        let list = parse_filters_json(r#"[{"field":"close","op":">","value":100}]"#).expect("json");
292        apply_filters_json(&mut s, Asset::Stock, &list).expect("apply json");
293        let payload = s.build_payload().expect("payload");
294        let filters = payload["filter"].as_array().expect("filters");
295        assert!(filters
296            .iter()
297            .any(|f| f["left"] == "close" && f["operation"] == "greater"));
298    }
299}