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