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