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