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