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
292fn tracked_query_differs(
296 local_sql: &str,
297 local_metadata: &QueryMetadata,
298 server: &crate::models::Query,
299) -> bool {
300 local_sql != server.sql
301 || local_metadata.name != server.name
302 || local_metadata.data_source_id != server.data_source_id
303 || serde_json::to_value(&local_metadata.options).ok()
304 != serde_json::to_value(&server.options).ok()
305}
306
307async fn sync_if_changed(
308 client: &RedashClient,
309 query_id: u64,
310 local_sql: &str,
311 local_metadata: &QueryMetadata,
312) -> Result<()> {
313 let server = client.get_query(query_id).await?;
314
315 if !tracked_query_differs(local_sql, local_metadata, &server) {
316 return Ok(());
317 }
318
319 eprintln!("Local changes detected, deploying query {query_id}...");
320 super::deploy::deploy_one(client, query_id, &local_metadata.name).await?;
321 eprintln!("Deployed.");
322
323 Ok(())
324}
325
326fn tracked_source_line(query_id: u64) -> String {
327 format!("server-stored query {query_id} (kept in sync with your local copy)")
328}
329
330fn params_from_cli(
331 cli_params: &[(String, serde_json::Value)],
332) -> Option<HashMap<String, serde_json::Value>> {
333 if cli_params.is_empty() {
334 None
335 } else {
336 Some(cli_params.iter().cloned().collect())
337 }
338}
339
340fn print_parameters(parameters: Option<&HashMap<String, serde_json::Value>>) {
341 if let Some(params) = parameters {
342 eprintln!("Parameters:");
343 for (name, value) in params {
344 eprintln!(" {name} = {value}");
345 }
346 eprintln!();
347 }
348}
349
350#[derive(Debug, PartialEq, Eq)]
351enum AdhocSource {
352 File(String),
353 Stdin,
354}
355
356fn adhoc_source(file: Option<&str>) -> AdhocSource {
357 match file {
358 Some("-") | None => AdhocSource::Stdin,
359 Some(path) => AdhocSource::File(path.to_string()),
360 }
361}
362
363fn read_sql<R: std::io::Read>(mut reader: R) -> Result<String> {
364 let mut sql = String::new();
365 reader
366 .read_to_string(&mut sql)
367 .context("Failed to read SQL from stdin")?;
368 if sql.trim().is_empty() {
369 bail!("No SQL provided on stdin.");
370 }
371 Ok(sql)
372}
373
374fn load_adhoc_sql(source: &AdhocSource) -> Result<(String, String)> {
375 match source {
376 AdhocSource::File(path) => {
377 let sql = fs::read_to_string(path).context(format!("Failed to read {path}"))?;
378 Ok((sql, path.clone()))
379 }
380 AdhocSource::Stdin => {
381 if std::io::stdin().is_terminal() {
382 bail!("No SQL on stdin. Pipe SQL in or pass --file <path>.");
383 }
384 let sql = read_sql(std::io::stdin().lock())?;
385 Ok((sql, "<stdin>".to_string()))
386 }
387 }
388}
389
390async fn execute_adhoc(
391 client: &RedashClient,
392 file: Option<&str>,
393 data_source_id: u64,
394 cli_params: &[(String, serde_json::Value)],
395 timeout_secs: u64,
396) -> Result<crate::models::QueryResult> {
397 let (sql, source_label) = load_adhoc_sql(&adhoc_source(file))?;
398
399 eprintln!("Source: {source_label} (ad-hoc, data source {data_source_id})\n");
400
401 let parameters = params_from_cli(cli_params);
402 print_parameters(parameters.as_ref());
403
404 client
405 .execute_adhoc_with_polling(&sql, data_source_id, parameters, timeout_secs, 500)
406 .await
407}
408
409async fn execute_tracked_query(
410 client: &RedashClient,
411 query_id: u64,
412 cli_params: &[(String, serde_json::Value)],
413 interactive: bool,
414 timeout_secs: u64,
415) -> Result<crate::models::QueryResult> {
416 let (metadata, sql, _yaml_path) = load_query_metadata_by_id(query_id)?;
417
418 sync_if_changed(client, query_id, &sql, &metadata).await?;
419
420 eprintln!("Executing query: {} - {}", metadata.id, metadata.name);
421 eprintln!("Source: {}\n", tracked_source_line(metadata.id));
422
423 let has_tty = std::io::stdin().is_terminal();
424 let parameters = build_parameter_map(&metadata, cli_params, interactive, has_tty)?;
425 print_parameters(parameters.as_ref());
426
427 client
428 .execute_query_with_polling(query_id, parameters, timeout_secs, 500)
429 .await
430}
431
432pub struct ExecuteArgs {
433 pub query_id: Option<u64>,
434 pub data_source: Option<u64>,
435 pub file: Option<String>,
436 pub param_args: Vec<String>,
437 pub format: OutputFormat,
438 pub interactive: bool,
439 pub timeout_secs: u64,
440 pub limit_rows: Option<usize>,
441}
442
443#[derive(Debug)]
447enum ExecuteMode {
448 Adhoc {
449 file: Option<String>,
450 data_source_id: u64,
451 },
452 Tracked {
453 query_id: u64,
454 },
455}
456
457fn resolve_mode(args: &ExecuteArgs) -> Result<ExecuteMode> {
458 if args.file.is_some() && args.query_id.is_some() {
459 bail!("Cannot combine a query ID with --file; choose one.");
460 }
461 if args.query_id.is_some() && args.data_source.is_some() {
462 bail!("--data-source cannot be combined with a query ID; it only applies to ad-hoc SQL.");
463 }
464
465 if let Some(query_id) = args.query_id {
466 return Ok(ExecuteMode::Tracked { query_id });
467 }
468
469 if args.file.is_some() || args.data_source.is_some() {
470 let data_source_id = args
471 .data_source
472 .context("ad-hoc execution requires --data-source <id> to run SQL")?;
473 return Ok(ExecuteMode::Adhoc {
474 file: args.file.clone(),
475 data_source_id,
476 });
477 }
478
479 bail!(
480 "No query specified. Provide a query ID (stmo-cli execute 123) \
481 or run ad-hoc SQL with --file <path> --data-source <id> (or pipe SQL via stdin)."
482 );
483}
484
485pub async fn execute(client: &RedashClient, args: ExecuteArgs) -> Result<()> {
486 let mode = resolve_mode(&args)?;
487
488 let cli_params: Vec<(String, serde_json::Value)> = args
489 .param_args
490 .iter()
491 .map(|arg| parse_parameter_arg(arg))
492 .collect::<Result<Vec<_>>>()?;
493
494 let result = match mode {
495 ExecuteMode::Adhoc {
496 file,
497 data_source_id,
498 } => {
499 execute_adhoc(
500 client,
501 file.as_deref(),
502 data_source_id,
503 &cli_params,
504 args.timeout_secs,
505 )
506 .await?
507 }
508 ExecuteMode::Tracked { query_id } => {
509 execute_tracked_query(
510 client,
511 query_id,
512 &cli_params,
513 args.interactive,
514 args.timeout_secs,
515 )
516 .await?
517 }
518 };
519
520 match args.format {
521 OutputFormat::Json => {
522 let json = format_results_json(&result, args.limit_rows)?;
523 println!("{json}");
524 }
525 OutputFormat::Table => {
526 let table = format_results_table(&result, args.limit_rows);
527 println!("{table}");
528 }
529 }
530
531 Ok(())
532}
533
534#[cfg(test)]
535#[allow(clippy::missing_errors_doc)]
536mod tests {
537 use super::*;
538 use crate::models::{Column, QueryOptions, QueryResult, QueryResultData};
539
540 fn make_query_metadata(name: &str, data_source_id: u64) -> QueryMetadata {
541 QueryMetadata {
542 id: 1,
543 name: name.to_string(),
544 description: None,
545 data_source_id,
546 user_id: None,
547 schedule: None,
548 options: QueryOptions { parameters: vec![] },
549 visualizations: vec![],
550 tags: None,
551 }
552 }
553
554 fn make_server_query(sql: &str, name: &str, data_source_id: u64) -> crate::models::Query {
555 crate::models::Query {
556 id: 1,
557 name: name.to_string(),
558 description: None,
559 sql: sql.to_string(),
560 data_source_id,
561 user: None,
562 schedule: None,
563 options: QueryOptions { parameters: vec![] },
564 visualizations: vec![],
565 tags: None,
566 is_archived: false,
567 is_draft: false,
568 updated_at: String::new(),
569 created_at: String::new(),
570 }
571 }
572
573 #[test]
574 fn test_tracked_query_differs_false_when_identical() {
575 let metadata = make_query_metadata("Q", 1);
576 let server = make_server_query("SELECT 1", "Q", 1);
577 assert!(!tracked_query_differs("SELECT 1", &metadata, &server));
578 }
579
580 #[test]
581 fn test_tracked_query_differs_true_when_sql_differs() {
582 let metadata = make_query_metadata("Q", 1);
583 let server = make_server_query("SELECT 1", "Q", 1);
584 assert!(tracked_query_differs("SELECT 2", &metadata, &server));
585 }
586
587 #[test]
588 fn test_tracked_query_differs_true_when_name_differs() {
589 let metadata = make_query_metadata("Local Name", 1);
590 let server = make_server_query("SELECT 1", "Server Name", 1);
591 assert!(tracked_query_differs("SELECT 1", &metadata, &server));
592 }
593
594 #[test]
595 fn test_tracked_query_differs_true_when_data_source_id_differs() {
596 let metadata = make_query_metadata("Q", 1);
597 let server = make_server_query("SELECT 1", "Q", 2);
598 assert!(tracked_query_differs("SELECT 1", &metadata, &server));
599 }
600
601 #[test]
602 fn test_tracked_query_differs_true_when_parameters_differ() {
603 let mut metadata = make_query_metadata("Q", 1);
604 metadata.options.parameters.push(Parameter {
605 name: "p".to_string(),
606 title: "P".to_string(),
607 param_type: "text".to_string(),
608 value: None,
609 enum_options: None,
610 query_id: None,
611 multi_values_options: None,
612 });
613 let server = make_server_query("SELECT 1", "Q", 1);
614 assert!(tracked_query_differs("SELECT 1", &metadata, &server));
615 }
616
617 #[test]
618 fn test_tracked_source_line_identifies_server_stored_query() {
619 let line = tracked_source_line(121_870);
620 assert!(line.contains("121870"));
621 assert!(line.contains("server-stored"));
622 }
623
624 fn make_execute_args(
625 query_id: Option<u64>,
626 data_source: Option<u64>,
627 file: Option<&str>,
628 ) -> ExecuteArgs {
629 ExecuteArgs {
630 query_id,
631 data_source,
632 file: file.map(str::to_string),
633 param_args: vec![],
634 format: OutputFormat::Json,
635 interactive: false,
636 timeout_secs: 300,
637 limit_rows: None,
638 }
639 }
640
641 #[test]
642 fn test_resolve_mode_query_id_only_is_tracked() {
643 let args = make_execute_args(Some(123), None, None);
644 let mode = resolve_mode(&args).unwrap();
645 assert!(matches!(mode, ExecuteMode::Tracked { query_id: 123 }));
646 }
647
648 #[test]
649 fn test_resolve_mode_data_source_only_is_adhoc() {
650 let args = make_execute_args(None, Some(63), None);
651 let mode = resolve_mode(&args).unwrap();
652 assert!(matches!(
653 mode,
654 ExecuteMode::Adhoc {
655 data_source_id: 63,
656 file: None
657 }
658 ));
659 }
660
661 #[test]
662 fn test_resolve_mode_data_source_with_file_is_adhoc() {
663 let args = make_execute_args(None, Some(63), Some("scratch.sql"));
664 let mode = resolve_mode(&args).unwrap();
665 match mode {
666 ExecuteMode::Adhoc {
667 data_source_id,
668 file,
669 } => {
670 assert_eq!(data_source_id, 63);
671 assert_eq!(file.as_deref(), Some("scratch.sql"));
672 }
673 ExecuteMode::Tracked { .. } => panic!("expected Adhoc"),
674 }
675 }
676
677 #[test]
678 fn test_resolve_mode_query_id_and_data_source_errors() {
679 let args = make_execute_args(Some(123), Some(63), None);
680 let err = resolve_mode(&args).unwrap_err();
681 assert!(err.to_string().contains("--data-source"));
682 }
683
684 #[test]
685 fn test_resolve_mode_query_id_and_file_errors() {
686 let args = make_execute_args(Some(123), None, Some("scratch.sql"));
687 let err = resolve_mode(&args).unwrap_err();
688 assert!(err.to_string().contains("--file"));
689 }
690
691 #[test]
692 fn test_resolve_mode_file_without_data_source_errors() {
693 let args = make_execute_args(None, None, Some("scratch.sql"));
694 let err = resolve_mode(&args).unwrap_err();
695 assert!(err.to_string().contains("--data-source"));
696 }
697
698 #[test]
699 fn test_resolve_mode_no_input_errors() {
700 let args = make_execute_args(None, None, None);
701 let err = resolve_mode(&args).unwrap_err();
702 assert!(err.to_string().contains("No query specified"));
703 }
704
705 #[test]
706 fn test_adhoc_source_none_is_stdin() {
707 assert_eq!(adhoc_source(None), AdhocSource::Stdin);
708 }
709
710 #[test]
711 fn test_adhoc_source_dash_is_stdin() {
712 assert_eq!(adhoc_source(Some("-")), AdhocSource::Stdin);
713 }
714
715 #[test]
716 fn test_adhoc_source_path_is_file() {
717 assert_eq!(
718 adhoc_source(Some("scratch.sql")),
719 AdhocSource::File("scratch.sql".to_string())
720 );
721 }
722
723 #[test]
724 fn test_read_sql_empty_input_errors() {
725 let err = read_sql(std::io::Cursor::new(b"" as &[u8])).unwrap_err();
726 assert!(err.to_string().contains("No SQL provided"));
727 }
728
729 #[test]
730 fn test_read_sql_whitespace_only_errors() {
731 let err = read_sql(std::io::Cursor::new(b" \n" as &[u8])).unwrap_err();
732 assert!(err.to_string().contains("No SQL provided"));
733 }
734
735 #[test]
736 fn test_read_sql_returns_content() {
737 let sql = read_sql(std::io::Cursor::new(b"SELECT 1" as &[u8])).unwrap();
738 assert_eq!(sql, "SELECT 1");
739 }
740
741 #[test]
742 fn test_parse_parameter_arg_string() {
743 let result = parse_parameter_arg("name=value").unwrap();
744 assert_eq!(result.0, "name");
745 assert_eq!(result.1, serde_json::Value::String("value".to_string()));
746 }
747
748 #[test]
749 fn test_parse_parameter_arg_json_array() {
750 let result = parse_parameter_arg("channels=[\"release\",\"beta\"]").unwrap();
751 assert_eq!(result.0, "channels");
752 assert_eq!(result.1, serde_json::json!(["release", "beta"]));
753 }
754
755 #[test]
756 fn test_parse_parameter_arg_number() {
757 let result = parse_parameter_arg("count=42").unwrap();
758 assert_eq!(result.0, "count");
759 assert_eq!(result.1, serde_json::json!(42));
760 }
761
762 #[test]
763 fn test_parse_parameter_arg_invalid() {
764 let result = parse_parameter_arg("invalid");
765 assert!(result.is_err());
766 assert!(
767 result
768 .unwrap_err()
769 .to_string()
770 .contains("Invalid parameter format")
771 );
772 }
773
774 #[test]
775 fn test_format_results_json() {
776 let result = QueryResult {
777 id: 1,
778 data: QueryResultData {
779 columns: vec![
780 Column {
781 name: "col1".to_string(),
782 type_name: Some("string".to_string()),
783 friendly_name: None,
784 },
785 Column {
786 name: "col2".to_string(),
787 type_name: Some("integer".to_string()),
788 friendly_name: None,
789 },
790 ],
791 rows: vec![
792 serde_json::json!({"col1": "value1", "col2": 123}),
793 serde_json::json!({"col1": "value2", "col2": 456}),
794 ],
795 },
796 runtime: 1.5,
797 retrieved_at: "2026-01-21T10:00:00".to_string(),
798 };
799
800 let json = format_results_json(&result, None).unwrap();
801 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
802
803 let rows = parsed.as_array().unwrap();
804 assert_eq!(rows.len(), 2);
805 assert_eq!(rows[0]["col1"], "value1");
806 assert_eq!(rows[0]["col2"], 123);
807 }
808
809 #[test]
810 fn test_format_results_json_with_limit() {
811 let result = QueryResult {
812 id: 1,
813 data: QueryResultData {
814 columns: vec![Column {
815 name: "col1".to_string(),
816 type_name: Some("string".to_string()),
817 friendly_name: None,
818 }],
819 rows: vec![
820 serde_json::json!({"col1": "row1"}),
821 serde_json::json!({"col1": "row2"}),
822 serde_json::json!({"col1": "row3"}),
823 ],
824 },
825 runtime: 1.0,
826 retrieved_at: "2026-01-21T10:00:00".to_string(),
827 };
828
829 let json = format_results_json(&result, Some(2)).unwrap();
830 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
831
832 assert_eq!(parsed.as_array().unwrap().len(), 2);
833 }
834
835 #[test]
836 fn test_format_results_table() {
837 let result = QueryResult {
838 id: 1,
839 data: QueryResultData {
840 columns: vec![
841 Column {
842 name: "col1".to_string(),
843 type_name: Some("string".to_string()),
844 friendly_name: None,
845 },
846 Column {
847 name: "col2".to_string(),
848 type_name: Some("integer".to_string()),
849 friendly_name: None,
850 },
851 ],
852 rows: vec![
853 serde_json::json!({"col1": "value1", "col2": 123}),
854 serde_json::json!({"col1": "value2", "col2": 456}),
855 ],
856 },
857 runtime: 1.5,
858 retrieved_at: "2026-01-21T10:00:00".to_string(),
859 };
860
861 let table = format_results_table(&result, None);
862
863 assert!(table.contains("col1"));
864 assert!(table.contains("col2"));
865 assert!(table.contains("value1"));
866 assert!(table.contains("value2"));
867 assert!(table.contains("2 rows returned"));
868 }
869
870 #[test]
871 fn test_format_results_table_with_limit() {
872 let result = QueryResult {
873 id: 1,
874 data: QueryResultData {
875 columns: vec![Column {
876 name: "col1".to_string(),
877 type_name: Some("string".to_string()),
878 friendly_name: None,
879 }],
880 rows: vec![
881 serde_json::json!({"col1": "row1"}),
882 serde_json::json!({"col1": "row2"}),
883 serde_json::json!({"col1": "row3"}),
884 ],
885 },
886 runtime: 1.0,
887 retrieved_at: "2026-01-21T10:00:00".to_string(),
888 };
889
890 let table = format_results_table(&result, Some(2));
891
892 assert!(table.contains("row1"));
893 assert!(table.contains("row2"));
894 assert!(table.contains("... 1 more rows"));
895 assert!(table.contains("3 rows returned"));
896 }
897
898 #[test]
899 fn test_format_results_table_truncation() {
900 let result = QueryResult {
901 id: 1,
902 data: QueryResultData {
903 columns: vec![Column {
904 name: "col1".to_string(),
905 type_name: Some("string".to_string()),
906 friendly_name: None,
907 }],
908 rows: vec![
909 serde_json::json!({"col1": "this_is_a_very_long_value_that_should_be_truncated"}),
910 ],
911 },
912 runtime: 1.0,
913 retrieved_at: "2026-01-21T10:00:00".to_string(),
914 };
915
916 let table = format_results_table(&result, None);
917
918 assert!(table.contains("..."));
919 }
920
921 #[test]
922 fn test_output_format_from_str() {
923 assert!(matches!(
924 "json".parse::<OutputFormat>().unwrap(),
925 OutputFormat::Json
926 ));
927 assert!(matches!(
928 "JSON".parse::<OutputFormat>().unwrap(),
929 OutputFormat::Json
930 ));
931 assert!(matches!(
932 "table".parse::<OutputFormat>().unwrap(),
933 OutputFormat::Table
934 ));
935 assert!(matches!(
936 "TABLE".parse::<OutputFormat>().unwrap(),
937 OutputFormat::Table
938 ));
939 }
940
941 #[test]
942 fn test_output_format_from_str_invalid() {
943 let result = "csv".parse::<OutputFormat>();
944 assert!(result.is_err());
945 assert!(result.unwrap_err().to_string().contains("Invalid format"));
946 }
947
948 #[test]
949 fn test_coerce_for_type_text_coerces_number_to_string() {
950 let result = coerce_for_type(&serde_json::json!(90), "text");
951 assert_eq!(result, serde_json::Value::String("90".to_string()));
952 }
953
954 #[test]
955 fn test_coerce_for_type_text_leaves_string_unchanged() {
956 let result = coerce_for_type(&serde_json::json!("90"), "text");
957 assert_eq!(result, serde_json::Value::String("90".to_string()));
958 }
959
960 #[test]
961 fn test_coerce_for_type_date_coerces_to_string() {
962 let result = coerce_for_type(&serde_json::json!(20_260_507), "date");
963 assert_eq!(result, serde_json::Value::String("20260507".to_string()));
964 }
965
966 #[test]
967 fn test_coerce_for_type_number_leaves_number_unchanged() {
968 let result = coerce_for_type(&serde_json::json!(42), "number");
969 assert_eq!(result, serde_json::json!(42));
970 }
971
972 fn make_metadata_with_param(name: &str, default: Option<serde_json::Value>) -> QueryMetadata {
973 make_metadata_with_typed_param(name, "text", default)
974 }
975
976 fn make_metadata_with_typed_param(
977 name: &str,
978 param_type: &str,
979 default: Option<serde_json::Value>,
980 ) -> QueryMetadata {
981 use crate::models::{Parameter, QueryOptions};
982 QueryMetadata {
983 id: 1,
984 name: "test".to_string(),
985 description: None,
986 data_source_id: 1,
987 user_id: None,
988 schedule: None,
989 options: QueryOptions {
990 parameters: vec![Parameter {
991 name: name.to_string(),
992 title: name.to_string(),
993 param_type: param_type.to_string(),
994 value: default,
995 enum_options: None,
996 query_id: None,
997 multi_values_options: None,
998 }],
999 },
1000 visualizations: vec![],
1001 tags: None,
1002 }
1003 }
1004
1005 #[test]
1006 fn test_build_parameter_map_coerces_text_param() {
1007 let metadata = make_metadata_with_param("days", None);
1008 let cli_params = vec![("days".to_string(), serde_json::json!(90))];
1009 let result = build_parameter_map(&metadata, &cli_params, false, false)
1010 .unwrap()
1011 .unwrap();
1012 assert_eq!(result["days"], serde_json::Value::String("90".to_string()));
1013 }
1014
1015 #[test]
1016 fn test_build_parameter_map_interactive_no_tty_uses_default() {
1017 let metadata = make_metadata_with_param("p", Some(serde_json::json!("default_val")));
1018 let result = build_parameter_map(&metadata, &[], true, false).unwrap();
1019 let map = result.unwrap();
1020 assert_eq!(map["p"], serde_json::json!("default_val"));
1021 }
1022
1023 #[test]
1024 fn test_build_parameter_map_interactive_no_tty_no_default_bails() {
1025 let metadata = make_metadata_with_param("p", None);
1026 let err = build_parameter_map(&metadata, &[], true, false).unwrap_err();
1027 assert!(err.to_string().contains("--param p="));
1028 }
1029
1030 #[test]
1031 fn test_build_parameter_map_interactive_no_tty_cli_param_overrides() {
1032 let metadata = make_metadata_with_param("p", None);
1033 let cli_params = vec![("p".to_string(), serde_json::json!("provided"))];
1034 let result = build_parameter_map(&metadata, &cli_params, true, false).unwrap();
1035 let map = result.unwrap();
1036 assert_eq!(map["p"], serde_json::json!("provided"));
1037 }
1038
1039 #[test]
1040 fn test_build_parameter_map_interactive_tty_cli_param_skips_prompt() {
1041 let metadata = make_metadata_with_param("p", None);
1042 let cli_params = vec![("p".to_string(), serde_json::json!("provided"))];
1043 let result = build_parameter_map(&metadata, &cli_params, true, true).unwrap();
1046 let map = result.unwrap();
1047 assert_eq!(map["p"], serde_json::json!("provided"));
1048 }
1049
1050 #[test]
1051 fn test_build_parameter_map_resolves_range_default_token() {
1052 use chrono::{Duration, Local};
1053 let metadata = make_metadata_with_typed_param(
1054 "range",
1055 "date-range",
1056 Some(serde_json::json!("d_last_7_days")),
1057 );
1058 let map = build_parameter_map(&metadata, &[], false, false)
1059 .unwrap()
1060 .unwrap();
1061 let range = &map["range"];
1062
1063 let today = Local::now().naive_local().date();
1064 let expected_start = (today - Duration::days(7)).format("%Y-%m-%d").to_string();
1065 let expected_end = today.format("%Y-%m-%d").to_string();
1066 assert_eq!(range["start"], serde_json::json!(expected_start));
1067 assert_eq!(range["end"], serde_json::json!(expected_end));
1068 }
1069
1070 #[test]
1071 fn test_build_parameter_map_resolves_cli_date_token() {
1072 use chrono::Local;
1073 let metadata = make_metadata_with_typed_param("d", "date", None);
1074 let cli_params = vec![("d".to_string(), serde_json::json!("d_now"))];
1075 let map = build_parameter_map(&metadata, &cli_params, false, false)
1076 .unwrap()
1077 .unwrap();
1078 let expected = Local::now().naive_local().format("%Y-%m-%d").to_string();
1079 assert_eq!(map["d"], serde_json::json!(expected));
1080 }
1081
1082 #[test]
1083 fn test_build_parameter_map_leaves_text_token_literal() {
1084 let metadata = make_metadata_with_typed_param("t", "text", None);
1085 let cli_params = vec![("t".to_string(), serde_json::json!("d_now"))];
1086 let map = build_parameter_map(&metadata, &cli_params, false, false)
1087 .unwrap()
1088 .unwrap();
1089 assert_eq!(map["t"], serde_json::json!("d_now"));
1090 }
1091
1092 #[test]
1093 fn test_build_parameter_map_resolves_dynamic_date_range_default() {
1094 use crate::models::{Parameter, QueryOptions};
1095 let metadata = QueryMetadata {
1096 id: 1,
1097 name: "test".to_string(),
1098 description: None,
1099 data_source_id: 1,
1100 user_id: None,
1101 schedule: None,
1102 options: QueryOptions {
1103 parameters: vec![Parameter {
1104 name: "period".to_string(),
1105 title: "period".to_string(),
1106 param_type: "date-range".to_string(),
1107 value: Some(serde_json::json!("d_last_7_days")),
1108 enum_options: None,
1109 query_id: None,
1110 multi_values_options: None,
1111 }],
1112 },
1113 visualizations: vec![],
1114 tags: None,
1115 };
1116
1117 let map = build_parameter_map(&metadata, &[], false, false)
1118 .unwrap()
1119 .unwrap();
1120 let period = map.get("period").unwrap();
1121 assert!(
1122 period.get("start").is_some(),
1123 "expected resolved start: {period}"
1124 );
1125 assert!(
1126 period.get("end").is_some(),
1127 "expected resolved end: {period}"
1128 );
1129 }
1130
1131 #[test]
1132 fn test_build_parameter_map_interactive_tty_cli_param_coerces() {
1133 let metadata = make_metadata_with_param("days", None);
1134 let cli_params = vec![("days".to_string(), serde_json::json!(90))];
1135 let result = build_parameter_map(&metadata, &cli_params, true, true)
1136 .unwrap()
1137 .unwrap();
1138 assert_eq!(result["days"], serde_json::Value::String("90".to_string()));
1139 }
1140
1141 #[test]
1142 fn test_build_parameter_map_non_interactive_tty_uses_default() {
1143 let metadata = make_metadata_with_param("p", Some(serde_json::json!("default_val")));
1144 let result = build_parameter_map(&metadata, &[], false, true).unwrap();
1146 let map = result.unwrap();
1147 assert_eq!(map["p"], serde_json::json!("default_val"));
1148 }
1149
1150 #[test]
1151 fn test_build_parameter_map_non_interactive_tty_no_default_bails() {
1152 let metadata = make_metadata_with_param("p", None);
1153 let err = build_parameter_map(&metadata, &[], false, true).unwrap_err();
1154 assert!(err.to_string().contains("Missing required parameter"));
1155 }
1156}