tvscreener/
query_config.rs1use 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
12pub 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
28pub 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
51pub 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
63pub 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
94pub 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
114pub 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#[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
149pub 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
167pub 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
188pub 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}