Skip to main content

stmo_cli/commands/
execute.rs

1#![allow(clippy::missing_errors_doc)]
2#![allow(clippy::missing_panics_doc)]
3
4use super::OutputFormat;
5use crate::api::RedashClient;
6use crate::models::{Parameter, QueryMetadata};
7use anyhow::{Context, Result, bail};
8use std::collections::HashMap;
9use std::fs;
10use std::io::IsTerminal;
11use std::path::Path;
12
13fn parse_parameter_arg(arg: &str) -> Result<(String, serde_json::Value)> {
14    let parts: Vec<&str> = arg.splitn(2, '=').collect();
15    if parts.len() != 2 {
16        bail!("Invalid parameter format. Use: --param name=value");
17    }
18
19    let name = parts[0].to_string();
20    let value_str = parts[1];
21
22    let value = if let Ok(json_value) = serde_json::from_str(value_str) {
23        json_value
24    } else {
25        serde_json::Value::String(value_str.to_string())
26    };
27
28    Ok((name, value))
29}
30
31fn load_query_metadata_by_id(query_id: u64) -> Result<(QueryMetadata, String, String)> {
32    let queries_dir = Path::new("queries");
33
34    for entry in fs::read_dir(queries_dir).context("Failed to read queries directory")? {
35        let entry = entry.context("Failed to read directory entry")?;
36        let path = entry.path();
37
38        if path.extension().is_some_and(|ext| ext == "yaml")
39            && let Some(filename) = path.file_name().and_then(|f| f.to_str())
40            && let Some(id_str) = filename.split('-').next()
41            && let Ok(id) = id_str.parse::<u64>()
42            && id == query_id
43        {
44            let yaml_content =
45                fs::read_to_string(&path).context(format!("Failed to read {}", path.display()))?;
46
47            let metadata: QueryMetadata = serde_yaml::from_str(&yaml_content)
48                .context(format!("Failed to parse {}", path.display()))?;
49
50            let yaml_path = path.display().to_string();
51            let sql_path = yaml_path.replace(".yaml", ".sql");
52
53            if !Path::new(&sql_path).exists() {
54                bail!("SQL file not found: {sql_path}");
55            }
56
57            let sql =
58                fs::read_to_string(&sql_path).context(format!("Failed to read {sql_path}"))?;
59
60            return Ok((metadata, sql, yaml_path));
61        }
62    }
63
64    bail!(
65        "Query {query_id} not found in queries/ directory. Run 'stmo-cli fetch {query_id}' first."
66    );
67}
68
69fn prompt_for_parameter(param: &Parameter) -> Result<serde_json::Value> {
70    use dialoguer::{Input, Select};
71
72    let title = &param.title;
73
74    match param.param_type.as_str() {
75        "date" => {
76            let input: String = Input::new()
77                .with_prompt(format!("{title} (YYYY-MM-DD)"))
78                .interact_text()?;
79            Ok(serde_json::Value::String(input))
80        }
81        "enum" => {
82            if let Some(enum_options) = &param.enum_options {
83                let options: Vec<&str> = enum_options.lines().collect();
84
85                if param.multi_values_options.is_some() {
86                    use dialoguer::MultiSelect;
87                    let selections = MultiSelect::new()
88                        .with_prompt(title)
89                        .items(&options)
90                        .interact()?;
91
92                    let selected: Vec<String> =
93                        selections.iter().map(|&i| options[i].to_string()).collect();
94
95                    Ok(serde_json::Value::Array(
96                        selected
97                            .into_iter()
98                            .map(serde_json::Value::String)
99                            .collect(),
100                    ))
101                } else {
102                    let selection = Select::new()
103                        .with_prompt(title)
104                        .items(&options)
105                        .default(0)
106                        .interact()?;
107
108                    Ok(serde_json::Value::String(options[selection].to_string()))
109                }
110            } else {
111                let input: String = Input::new().with_prompt(title).interact_text()?;
112                Ok(serde_json::Value::String(input))
113            }
114        }
115        "number" => {
116            let input: f64 = Input::new().with_prompt(title).interact_text()?;
117            Ok(serde_json::json!(input))
118        }
119        _ => {
120            let input: String = Input::new().with_prompt(title).interact_text()?;
121            Ok(serde_json::Value::String(input))
122        }
123    }
124}
125
126fn coerce_for_type(value: &serde_json::Value, param_type: &str) -> serde_json::Value {
127    match param_type {
128        "text" | "date" => match value {
129            serde_json::Value::String(_) => value.clone(),
130            other => serde_json::Value::String(other.to_string()),
131        },
132        _ => value.clone(),
133    }
134}
135
136fn build_parameter_map(
137    metadata: &QueryMetadata,
138    cli_params: &[(String, serde_json::Value)],
139    interactive: bool,
140    has_tty: bool,
141) -> Result<Option<HashMap<String, serde_json::Value>>> {
142    if metadata.options.parameters.is_empty() {
143        return Ok(None);
144    }
145
146    let mut param_map = HashMap::new();
147
148    for (name, value) in cli_params {
149        let coerced = metadata
150            .options
151            .parameters
152            .iter()
153            .find(|p| p.name == *name)
154            .map_or_else(|| value.clone(), |p| coerce_for_type(value, &p.param_type));
155        param_map.insert(name.clone(), coerced);
156    }
157
158    for param in &metadata.options.parameters {
159        if !param_map.contains_key(&param.name) {
160            if interactive && has_tty {
161                eprintln!("\nParameter '{}' required:", param.title);
162                let value = prompt_for_parameter(param)?;
163                param_map.insert(param.name.clone(), value);
164            } else if interactive && !has_tty {
165                if let Some(default_value) = &param.value {
166                    param_map.insert(param.name.clone(), default_value.clone());
167                } else {
168                    bail!(
169                        "No TTY available for interactive prompt. \
170                         Supply parameter explicitly: --param {}=<value>",
171                        param.name
172                    );
173                }
174            } else if let Some(default_value) = &param.value {
175                param_map.insert(param.name.clone(), default_value.clone());
176            } else {
177                bail!(
178                    "Missing required parameter: '{}' ({}). Use --param {}=value or --interactive",
179                    param.name,
180                    param.title,
181                    param.name
182                );
183            }
184        }
185    }
186
187    resolve_dynamic_dates(&metadata.options.parameters, &mut param_map);
188
189    Ok(if param_map.is_empty() {
190        None
191    } else {
192        Some(param_map)
193    })
194}
195
196// Resolve `d_*` tokens (from `--param` or a stored parameter default) before executing a
197// tracked query. The stored-query API rejects raw `d_*` values — only Redash's frontend
198// expands them — so this must run client-side first. See `dynamic_dates`. Ad-hoc execution
199// has no parameter schema and sends `--param` values verbatim, so this does not apply there.
200fn resolve_dynamic_dates(
201    parameters: &[Parameter],
202    param_map: &mut HashMap<String, serde_json::Value>,
203) {
204    for param in parameters {
205        if let Some(value) = param_map.get(&param.name)
206            && let Some(resolved) = super::dynamic_dates::resolve(value, &param.param_type)
207        {
208            param_map.insert(param.name.clone(), resolved);
209        }
210    }
211}
212
213fn format_results_json(
214    result: &crate::models::QueryResult,
215    limit: Option<usize>,
216) -> Result<String> {
217    let rows = if let Some(limit) = limit {
218        result
219            .data
220            .rows
221            .iter()
222            .take(limit)
223            .cloned()
224            .collect::<Vec<_>>()
225    } else {
226        result.data.rows.clone()
227    };
228
229    serde_json::to_string_pretty(&rows).context("Failed to format results as JSON")
230}
231
232fn format_results_table(result: &crate::models::QueryResult, limit: Option<usize>) -> String {
233    use std::fmt::Write;
234
235    let mut output = String::new();
236
237    let _ = writeln!(output);
238    for col in &result.data.columns {
239        let _ = write!(output, "{:20} ", col.name);
240    }
241    let _ = writeln!(output);
242    let _ = writeln!(output, "{}", "-".repeat(result.data.columns.len() * 21));
243
244    let rows_to_show = limit
245        .unwrap_or(result.data.rows.len())
246        .min(result.data.rows.len());
247
248    for row in &result.data.rows[..rows_to_show] {
249        if let serde_json::Value::Object(obj) = row {
250            for col in &result.data.columns {
251                let value = obj
252                    .get(&col.name)
253                    .map(|v| match v {
254                        serde_json::Value::Null => "NULL".to_string(),
255                        serde_json::Value::String(s) => s.clone(),
256                        _ => v.to_string(),
257                    })
258                    .unwrap_or_default();
259
260                let truncated = if value.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
293// Compare local vs. server on everything `deploy` would push, so `execute` never silently
294// runs a stale server copy. SQL is compared byte-for-byte, matching how `fetch`/`deploy`
295// round-trip it without modification.
296async 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// The validated execution mode. Resolving the flag combinations into this enum once keeps
433// the validation matrix in a single place and makes invalid combinations unrepresentable
434// downstream (e.g. ad-hoc always carries a concrete data source).
435#[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        // interactive + has_tty would prompt for a missing param (blocking on
977        // stdin); supplying it via CLI must satisfy it without prompting.
978        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        // has_tty alone must not trigger prompting when not interactive.
1078        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}