Skip to main content

stmo_cli/commands/
execute.rs

1#![allow(clippy::missing_errors_doc)]
2#![allow(clippy::missing_panics_doc)]
3
4use super::OutputFormat;
5use crate::api::RedashClient;
6use crate::models::{Parameter, QueryMetadata};
7use anyhow::{Context, Result, bail};
8use std::collections::HashMap;
9use std::fs;
10use std::io::IsTerminal;
11use std::path::Path;
12
13fn parse_parameter_arg(arg: &str) -> Result<(String, serde_json::Value)> {
14    let parts: Vec<&str> = arg.splitn(2, '=').collect();
15    if parts.len() != 2 {
16        bail!("Invalid parameter format. Use: --param name=value");
17    }
18
19    let name = parts[0].to_string();
20    let value_str = parts[1];
21
22    let value = if let Ok(json_value) = serde_json::from_str(value_str) {
23        json_value
24    } else {
25        serde_json::Value::String(value_str.to_string())
26    };
27
28    Ok((name, value))
29}
30
31fn load_query_metadata_by_id(query_id: u64) -> Result<(QueryMetadata, String, String)> {
32    let queries_dir = Path::new("queries");
33
34    for entry in fs::read_dir(queries_dir).context("Failed to read queries directory")? {
35        let entry = entry.context("Failed to read directory entry")?;
36        let path = entry.path();
37
38        if path.extension().is_some_and(|ext| ext == "yaml")
39            && let Some(filename) = path.file_name().and_then(|f| f.to_str())
40            && let Some(id_str) = filename.split('-').next()
41            && let Ok(id) = id_str.parse::<u64>()
42            && id == query_id
43        {
44            let yaml_content =
45                fs::read_to_string(&path).context(format!("Failed to read {}", path.display()))?;
46
47            let metadata: QueryMetadata = serde_yaml::from_str(&yaml_content)
48                .context(format!("Failed to parse {}", path.display()))?;
49
50            let yaml_path = path.display().to_string();
51            let sql_path = yaml_path.replace(".yaml", ".sql");
52
53            if !Path::new(&sql_path).exists() {
54                bail!("SQL file not found: {sql_path}");
55            }
56
57            let sql =
58                fs::read_to_string(&sql_path).context(format!("Failed to read {sql_path}"))?;
59
60            return Ok((metadata, sql, yaml_path));
61        }
62    }
63
64    bail!(
65        "Query {query_id} not found in queries/ directory. Run 'stmo-cli fetch {query_id}' first."
66    );
67}
68
69fn prompt_for_parameter(param: &Parameter) -> Result<serde_json::Value> {
70    use dialoguer::{Input, Select};
71
72    let title = &param.title;
73
74    match param.param_type.as_str() {
75        "date" => {
76            let input: String = Input::new()
77                .with_prompt(format!("{title} (YYYY-MM-DD)"))
78                .interact_text()?;
79            Ok(serde_json::Value::String(input))
80        }
81        "enum" => {
82            if let Some(enum_options) = &param.enum_options {
83                let options: Vec<&str> = enum_options.lines().collect();
84
85                if param.multi_values_options.is_some() {
86                    use dialoguer::MultiSelect;
87                    let selections = MultiSelect::new()
88                        .with_prompt(title)
89                        .items(&options)
90                        .interact()?;
91
92                    let selected: Vec<String> =
93                        selections.iter().map(|&i| options[i].to_string()).collect();
94
95                    Ok(serde_json::Value::Array(
96                        selected
97                            .into_iter()
98                            .map(serde_json::Value::String)
99                            .collect(),
100                    ))
101                } else {
102                    let selection = Select::new()
103                        .with_prompt(title)
104                        .items(&options)
105                        .default(0)
106                        .interact()?;
107
108                    Ok(serde_json::Value::String(options[selection].to_string()))
109                }
110            } else {
111                let input: String = Input::new().with_prompt(title).interact_text()?;
112                Ok(serde_json::Value::String(input))
113            }
114        }
115        "number" => {
116            let input: f64 = Input::new().with_prompt(title).interact_text()?;
117            Ok(serde_json::json!(input))
118        }
119        _ => {
120            let input: String = Input::new().with_prompt(title).interact_text()?;
121            Ok(serde_json::Value::String(input))
122        }
123    }
124}
125
126fn coerce_for_type(value: &serde_json::Value, param_type: &str) -> serde_json::Value {
127    match param_type {
128        "text" | "date" => match value {
129            serde_json::Value::String(_) => value.clone(),
130            other => serde_json::Value::String(other.to_string()),
131        },
132        _ => value.clone(),
133    }
134}
135
136fn build_parameter_map(
137    metadata: &QueryMetadata,
138    cli_params: &[(String, serde_json::Value)],
139    interactive: bool,
140    has_tty: bool,
141) -> Result<Option<HashMap<String, serde_json::Value>>> {
142    if metadata.options.parameters.is_empty() {
143        return Ok(None);
144    }
145
146    let mut param_map = HashMap::new();
147
148    for (name, value) in cli_params {
149        let coerced = metadata
150            .options
151            .parameters
152            .iter()
153            .find(|p| p.name == *name)
154            .map_or_else(|| value.clone(), |p| coerce_for_type(value, &p.param_type));
155        param_map.insert(name.clone(), coerced);
156    }
157
158    for param in &metadata.options.parameters {
159        if !param_map.contains_key(&param.name) {
160            if interactive && has_tty {
161                eprintln!("\nParameter '{}' required:", param.title);
162                let value = prompt_for_parameter(param)?;
163                param_map.insert(param.name.clone(), value);
164            } else if interactive && !has_tty {
165                if let Some(default_value) = &param.value {
166                    param_map.insert(param.name.clone(), default_value.clone());
167                } else {
168                    bail!(
169                        "No TTY available for interactive prompt. \
170                         Supply parameter explicitly: --param {}=<value>",
171                        param.name
172                    );
173                }
174            } else if let Some(default_value) = &param.value {
175                param_map.insert(param.name.clone(), default_value.clone());
176            } else {
177                bail!(
178                    "Missing required parameter: '{}' ({}). Use --param {}=value or --interactive",
179                    param.name,
180                    param.title,
181                    param.name
182                );
183            }
184        }
185    }
186
187    resolve_dynamic_dates(&metadata.options.parameters, &mut param_map);
188
189    Ok(if param_map.is_empty() {
190        None
191    } else {
192        Some(param_map)
193    })
194}
195
196// Resolve `d_*` tokens (from `--param` or a stored parameter default) before either
197// execution path runs. The stored-query API rejects raw `d_*` and the ad-hoc API passes
198// it through literally — only Redash's frontend expands them — so this must apply to both
199// the local and `--remote` paths. See `dynamic_dates`.
200fn resolve_dynamic_dates(
201    parameters: &[Parameter],
202    param_map: &mut HashMap<String, serde_json::Value>,
203) {
204    for param in parameters {
205        if let Some(value) = param_map.get(&param.name)
206            && let Some(resolved) = super::dynamic_dates::resolve(value, &param.param_type)
207        {
208            param_map.insert(param.name.clone(), resolved);
209        }
210    }
211}
212
213fn format_results_json(
214    result: &crate::models::QueryResult,
215    limit: Option<usize>,
216) -> Result<String> {
217    let rows = if let Some(limit) = limit {
218        result
219            .data
220            .rows
221            .iter()
222            .take(limit)
223            .cloned()
224            .collect::<Vec<_>>()
225    } else {
226        result.data.rows.clone()
227    };
228
229    serde_json::to_string_pretty(&rows).context("Failed to format results as JSON")
230}
231
232fn format_results_table(result: &crate::models::QueryResult, limit: Option<usize>) -> String {
233    use std::fmt::Write;
234
235    let mut output = String::new();
236
237    let _ = writeln!(output);
238    for col in &result.data.columns {
239        let _ = write!(output, "{:20} ", col.name);
240    }
241    let _ = writeln!(output);
242    let _ = writeln!(output, "{}", "-".repeat(result.data.columns.len() * 21));
243
244    let rows_to_show = limit
245        .unwrap_or(result.data.rows.len())
246        .min(result.data.rows.len());
247
248    for row in &result.data.rows[..rows_to_show] {
249        if let serde_json::Value::Object(obj) = row {
250            for col in &result.data.columns {
251                let value = obj
252                    .get(&col.name)
253                    .map(|v| match v {
254                        serde_json::Value::Null => "NULL".to_string(),
255                        serde_json::Value::String(s) => s.clone(),
256                        _ => v.to_string(),
257                    })
258                    .unwrap_or_default();
259
260                let truncated = if value.len() > 18 {
261                    format!("{}...", &value[..15])
262                } else {
263                    value
264                };
265
266                let _ = write!(output, "{truncated:20} ");
267            }
268            let _ = writeln!(output);
269        }
270    }
271
272    if rows_to_show < result.data.rows.len() {
273        let _ = write!(
274            output,
275            "\n... {} more rows (showing {} of {})\n",
276            result.data.rows.len() - rows_to_show,
277            rows_to_show,
278            result.data.rows.len()
279        );
280    }
281
282    let _ = write!(
283        output,
284        "\nāœ“ {} rows returned in {:.2}s\n",
285        result.data.rows.len(),
286        result.runtime
287    );
288
289    output
290}
291
292pub async fn execute(
293    client: &RedashClient,
294    query_id: u64,
295    param_args: Vec<String>,
296    format: OutputFormat,
297    interactive: bool,
298    timeout_secs: u64,
299    limit_rows: Option<usize>,
300) -> Result<()> {
301    let (metadata, _sql, yaml_path) = load_query_metadata_by_id(query_id)?;
302
303    eprintln!("Executing query: {} - {}", metadata.id, metadata.name);
304    eprintln!("Source: {yaml_path}\n");
305
306    let cli_params: Vec<(String, serde_json::Value)> = param_args
307        .iter()
308        .map(|arg| parse_parameter_arg(arg))
309        .collect::<Result<Vec<_>>>()?;
310
311    let has_tty = std::io::stdin().is_terminal();
312    let parameters = build_parameter_map(&metadata, &cli_params, interactive, has_tty)?;
313
314    if let Some(ref params) = parameters {
315        eprintln!("Parameters:");
316        for (name, value) in params {
317            eprintln!("  {name} = {value}");
318        }
319        eprintln!();
320    }
321
322    let result = client
323        .execute_query_with_polling(query_id, parameters, timeout_secs, 500)
324        .await?;
325
326    match format {
327        OutputFormat::Json => {
328            let json = format_results_json(&result, limit_rows)?;
329            println!("{json}");
330        }
331        OutputFormat::Table => {
332            let table = format_results_table(&result, limit_rows);
333            println!("{table}");
334        }
335    }
336
337    Ok(())
338}
339
340#[cfg(test)]
341#[allow(clippy::missing_errors_doc)]
342mod tests {
343    use super::*;
344    use crate::models::{Column, QueryResult, QueryResultData};
345
346    #[test]
347    fn test_parse_parameter_arg_string() {
348        let result = parse_parameter_arg("name=value").unwrap();
349        assert_eq!(result.0, "name");
350        assert_eq!(result.1, serde_json::Value::String("value".to_string()));
351    }
352
353    #[test]
354    fn test_parse_parameter_arg_json_array() {
355        let result = parse_parameter_arg("channels=[\"release\",\"beta\"]").unwrap();
356        assert_eq!(result.0, "channels");
357        assert_eq!(result.1, serde_json::json!(["release", "beta"]));
358    }
359
360    #[test]
361    fn test_parse_parameter_arg_number() {
362        let result = parse_parameter_arg("count=42").unwrap();
363        assert_eq!(result.0, "count");
364        assert_eq!(result.1, serde_json::json!(42));
365    }
366
367    #[test]
368    fn test_parse_parameter_arg_invalid() {
369        let result = parse_parameter_arg("invalid");
370        assert!(result.is_err());
371        assert!(
372            result
373                .unwrap_err()
374                .to_string()
375                .contains("Invalid parameter format")
376        );
377    }
378
379    #[test]
380    fn test_format_results_json() {
381        let result = QueryResult {
382            id: 1,
383            data: QueryResultData {
384                columns: vec![
385                    Column {
386                        name: "col1".to_string(),
387                        type_name: "string".to_string(),
388                        friendly_name: None,
389                    },
390                    Column {
391                        name: "col2".to_string(),
392                        type_name: "integer".to_string(),
393                        friendly_name: None,
394                    },
395                ],
396                rows: vec![
397                    serde_json::json!({"col1": "value1", "col2": 123}),
398                    serde_json::json!({"col1": "value2", "col2": 456}),
399                ],
400            },
401            runtime: 1.5,
402            retrieved_at: "2026-01-21T10:00:00".to_string(),
403        };
404
405        let json = format_results_json(&result, None).unwrap();
406        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
407
408        let rows = parsed.as_array().unwrap();
409        assert_eq!(rows.len(), 2);
410        assert_eq!(rows[0]["col1"], "value1");
411        assert_eq!(rows[0]["col2"], 123);
412    }
413
414    #[test]
415    fn test_format_results_json_with_limit() {
416        let result = QueryResult {
417            id: 1,
418            data: QueryResultData {
419                columns: vec![Column {
420                    name: "col1".to_string(),
421                    type_name: "string".to_string(),
422                    friendly_name: None,
423                }],
424                rows: vec![
425                    serde_json::json!({"col1": "row1"}),
426                    serde_json::json!({"col1": "row2"}),
427                    serde_json::json!({"col1": "row3"}),
428                ],
429            },
430            runtime: 1.0,
431            retrieved_at: "2026-01-21T10:00:00".to_string(),
432        };
433
434        let json = format_results_json(&result, Some(2)).unwrap();
435        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
436
437        assert_eq!(parsed.as_array().unwrap().len(), 2);
438    }
439
440    #[test]
441    fn test_format_results_table() {
442        let result = QueryResult {
443            id: 1,
444            data: QueryResultData {
445                columns: vec![
446                    Column {
447                        name: "col1".to_string(),
448                        type_name: "string".to_string(),
449                        friendly_name: None,
450                    },
451                    Column {
452                        name: "col2".to_string(),
453                        type_name: "integer".to_string(),
454                        friendly_name: None,
455                    },
456                ],
457                rows: vec![
458                    serde_json::json!({"col1": "value1", "col2": 123}),
459                    serde_json::json!({"col1": "value2", "col2": 456}),
460                ],
461            },
462            runtime: 1.5,
463            retrieved_at: "2026-01-21T10:00:00".to_string(),
464        };
465
466        let table = format_results_table(&result, None);
467
468        assert!(table.contains("col1"));
469        assert!(table.contains("col2"));
470        assert!(table.contains("value1"));
471        assert!(table.contains("value2"));
472        assert!(table.contains("2 rows returned"));
473    }
474
475    #[test]
476    fn test_format_results_table_with_limit() {
477        let result = QueryResult {
478            id: 1,
479            data: QueryResultData {
480                columns: vec![Column {
481                    name: "col1".to_string(),
482                    type_name: "string".to_string(),
483                    friendly_name: None,
484                }],
485                rows: vec![
486                    serde_json::json!({"col1": "row1"}),
487                    serde_json::json!({"col1": "row2"}),
488                    serde_json::json!({"col1": "row3"}),
489                ],
490            },
491            runtime: 1.0,
492            retrieved_at: "2026-01-21T10:00:00".to_string(),
493        };
494
495        let table = format_results_table(&result, Some(2));
496
497        assert!(table.contains("row1"));
498        assert!(table.contains("row2"));
499        assert!(table.contains("... 1 more rows"));
500        assert!(table.contains("3 rows returned"));
501    }
502
503    #[test]
504    fn test_format_results_table_truncation() {
505        let result = QueryResult {
506            id: 1,
507            data: QueryResultData {
508                columns: vec![Column {
509                    name: "col1".to_string(),
510                    type_name: "string".to_string(),
511                    friendly_name: None,
512                }],
513                rows: vec![
514                    serde_json::json!({"col1": "this_is_a_very_long_value_that_should_be_truncated"}),
515                ],
516            },
517            runtime: 1.0,
518            retrieved_at: "2026-01-21T10:00:00".to_string(),
519        };
520
521        let table = format_results_table(&result, None);
522
523        assert!(table.contains("..."));
524    }
525
526    #[test]
527    fn test_output_format_from_str() {
528        assert!(matches!(
529            "json".parse::<OutputFormat>().unwrap(),
530            OutputFormat::Json
531        ));
532        assert!(matches!(
533            "JSON".parse::<OutputFormat>().unwrap(),
534            OutputFormat::Json
535        ));
536        assert!(matches!(
537            "table".parse::<OutputFormat>().unwrap(),
538            OutputFormat::Table
539        ));
540        assert!(matches!(
541            "TABLE".parse::<OutputFormat>().unwrap(),
542            OutputFormat::Table
543        ));
544    }
545
546    #[test]
547    fn test_output_format_from_str_invalid() {
548        let result = "csv".parse::<OutputFormat>();
549        assert!(result.is_err());
550        assert!(result.unwrap_err().to_string().contains("Invalid format"));
551    }
552
553    #[test]
554    fn test_coerce_for_type_text_coerces_number_to_string() {
555        let result = coerce_for_type(&serde_json::json!(90), "text");
556        assert_eq!(result, serde_json::Value::String("90".to_string()));
557    }
558
559    #[test]
560    fn test_coerce_for_type_text_leaves_string_unchanged() {
561        let result = coerce_for_type(&serde_json::json!("90"), "text");
562        assert_eq!(result, serde_json::Value::String("90".to_string()));
563    }
564
565    #[test]
566    fn test_coerce_for_type_date_coerces_to_string() {
567        let result = coerce_for_type(&serde_json::json!(20_260_507), "date");
568        assert_eq!(result, serde_json::Value::String("20260507".to_string()));
569    }
570
571    #[test]
572    fn test_coerce_for_type_number_leaves_number_unchanged() {
573        let result = coerce_for_type(&serde_json::json!(42), "number");
574        assert_eq!(result, serde_json::json!(42));
575    }
576
577    fn make_metadata_with_param(name: &str, default: Option<serde_json::Value>) -> QueryMetadata {
578        make_metadata_with_typed_param(name, "text", default)
579    }
580
581    fn make_metadata_with_typed_param(
582        name: &str,
583        param_type: &str,
584        default: Option<serde_json::Value>,
585    ) -> QueryMetadata {
586        use crate::models::{Parameter, QueryOptions};
587        QueryMetadata {
588            id: 1,
589            name: "test".to_string(),
590            description: None,
591            data_source_id: 1,
592            user_id: None,
593            schedule: None,
594            options: QueryOptions {
595                parameters: vec![Parameter {
596                    name: name.to_string(),
597                    title: name.to_string(),
598                    param_type: param_type.to_string(),
599                    value: default,
600                    enum_options: None,
601                    query_id: None,
602                    multi_values_options: None,
603                }],
604            },
605            visualizations: vec![],
606            tags: None,
607        }
608    }
609
610    #[test]
611    fn test_build_parameter_map_coerces_text_param() {
612        let metadata = make_metadata_with_param("days", None);
613        let cli_params = vec![("days".to_string(), serde_json::json!(90))];
614        let result = build_parameter_map(&metadata, &cli_params, false, false)
615            .unwrap()
616            .unwrap();
617        assert_eq!(result["days"], serde_json::Value::String("90".to_string()));
618    }
619
620    #[test]
621    fn test_build_parameter_map_interactive_no_tty_uses_default() {
622        let metadata = make_metadata_with_param("p", Some(serde_json::json!("default_val")));
623        let result = build_parameter_map(&metadata, &[], true, false).unwrap();
624        let map = result.unwrap();
625        assert_eq!(map["p"], serde_json::json!("default_val"));
626    }
627
628    #[test]
629    fn test_build_parameter_map_interactive_no_tty_no_default_bails() {
630        let metadata = make_metadata_with_param("p", None);
631        let err = build_parameter_map(&metadata, &[], true, false).unwrap_err();
632        assert!(err.to_string().contains("--param p="));
633    }
634
635    #[test]
636    fn test_build_parameter_map_interactive_no_tty_cli_param_overrides() {
637        let metadata = make_metadata_with_param("p", None);
638        let cli_params = vec![("p".to_string(), serde_json::json!("provided"))];
639        let result = build_parameter_map(&metadata, &cli_params, true, false).unwrap();
640        let map = result.unwrap();
641        assert_eq!(map["p"], serde_json::json!("provided"));
642    }
643
644    #[test]
645    fn test_build_parameter_map_interactive_tty_cli_param_skips_prompt() {
646        let metadata = make_metadata_with_param("p", None);
647        let cli_params = vec![("p".to_string(), serde_json::json!("provided"))];
648        // interactive + has_tty would prompt for a missing param (blocking on
649        // stdin); supplying it via CLI must satisfy it without prompting.
650        let result = build_parameter_map(&metadata, &cli_params, true, true).unwrap();
651        let map = result.unwrap();
652        assert_eq!(map["p"], serde_json::json!("provided"));
653    }
654
655    #[test]
656    fn test_build_parameter_map_resolves_range_default_token() {
657        use chrono::{Duration, Local};
658        let metadata = make_metadata_with_typed_param(
659            "range",
660            "date-range",
661            Some(serde_json::json!("d_last_7_days")),
662        );
663        let map = build_parameter_map(&metadata, &[], false, false)
664            .unwrap()
665            .unwrap();
666        let range = &map["range"];
667
668        let today = Local::now().naive_local().date();
669        let expected_start = (today - Duration::days(7)).format("%Y-%m-%d").to_string();
670        let expected_end = today.format("%Y-%m-%d").to_string();
671        assert_eq!(range["start"], serde_json::json!(expected_start));
672        assert_eq!(range["end"], serde_json::json!(expected_end));
673    }
674
675    #[test]
676    fn test_build_parameter_map_resolves_cli_date_token() {
677        use chrono::Local;
678        let metadata = make_metadata_with_typed_param("d", "date", None);
679        let cli_params = vec![("d".to_string(), serde_json::json!("d_now"))];
680        let map = build_parameter_map(&metadata, &cli_params, false, false)
681            .unwrap()
682            .unwrap();
683        let expected = Local::now().naive_local().format("%Y-%m-%d").to_string();
684        assert_eq!(map["d"], serde_json::json!(expected));
685    }
686
687    #[test]
688    fn test_build_parameter_map_leaves_text_token_literal() {
689        let metadata = make_metadata_with_typed_param("t", "text", None);
690        let cli_params = vec![("t".to_string(), serde_json::json!("d_now"))];
691        let map = build_parameter_map(&metadata, &cli_params, false, false)
692            .unwrap()
693            .unwrap();
694        assert_eq!(map["t"], serde_json::json!("d_now"));
695    }
696
697    #[test]
698    fn test_build_parameter_map_resolves_dynamic_date_range_default() {
699        use crate::models::{Parameter, QueryOptions};
700        let metadata = QueryMetadata {
701            id: 1,
702            name: "test".to_string(),
703            description: None,
704            data_source_id: 1,
705            user_id: None,
706            schedule: None,
707            options: QueryOptions {
708                parameters: vec![Parameter {
709                    name: "period".to_string(),
710                    title: "period".to_string(),
711                    param_type: "date-range".to_string(),
712                    value: Some(serde_json::json!("d_last_7_days")),
713                    enum_options: None,
714                    query_id: None,
715                    multi_values_options: None,
716                }],
717            },
718            visualizations: vec![],
719            tags: None,
720        };
721
722        let map = build_parameter_map(&metadata, &[], false, false)
723            .unwrap()
724            .unwrap();
725        let period = map.get("period").unwrap();
726        assert!(
727            period.get("start").is_some(),
728            "expected resolved start: {period}"
729        );
730        assert!(
731            period.get("end").is_some(),
732            "expected resolved end: {period}"
733        );
734    }
735
736    #[test]
737    fn test_build_parameter_map_interactive_tty_cli_param_coerces() {
738        let metadata = make_metadata_with_param("days", None);
739        let cli_params = vec![("days".to_string(), serde_json::json!(90))];
740        let result = build_parameter_map(&metadata, &cli_params, true, true)
741            .unwrap()
742            .unwrap();
743        assert_eq!(result["days"], serde_json::Value::String("90".to_string()));
744    }
745
746    #[test]
747    fn test_build_parameter_map_non_interactive_tty_uses_default() {
748        let metadata = make_metadata_with_param("p", Some(serde_json::json!("default_val")));
749        // has_tty alone must not trigger prompting when not interactive.
750        let result = build_parameter_map(&metadata, &[], false, true).unwrap();
751        let map = result.unwrap();
752        assert_eq!(map["p"], serde_json::json!("default_val"));
753    }
754
755    #[test]
756    fn test_build_parameter_map_non_interactive_tty_no_default_bails() {
757        let metadata = make_metadata_with_param("p", None);
758        let err = build_parameter_map(&metadata, &[], false, true).unwrap_err();
759        assert!(err.to_string().contains("Missing required parameter"));
760    }
761}