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 = ¶m.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) = ¶m.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(¶m.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) = ¶m.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) = ¶m.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
196fn 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(¶m.name)
206 && let Some(resolved) = super::dynamic_dates::resolve(value, ¶m.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 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 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}