#![allow(clippy::missing_errors_doc)]
#![allow(clippy::missing_panics_doc)]
use anyhow::{Context, Result, bail};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use crate::api::RedashClient;
use crate::models::{QueryMetadata, Parameter};
use super::OutputFormat;
fn parse_parameter_arg(arg: &str) -> Result<(String, serde_json::Value)> {
let parts: Vec<&str> = arg.splitn(2, '=').collect();
if parts.len() != 2 {
bail!("Invalid parameter format. Use: --param name=value");
}
let name = parts[0].to_string();
let value_str = parts[1];
let value = if let Ok(json_value) = serde_json::from_str(value_str) {
json_value
} else {
serde_json::Value::String(value_str.to_string())
};
Ok((name, value))
}
fn load_query_metadata_by_id(query_id: u64) -> Result<(QueryMetadata, String, String)> {
let queries_dir = Path::new("queries");
for entry in fs::read_dir(queries_dir).context("Failed to read queries directory")? {
let entry = entry.context("Failed to read directory entry")?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "yaml")
&& let Some(filename) = path.file_name().and_then(|f| f.to_str())
&& let Some(id_str) = filename.split('-').next()
&& let Ok(id) = id_str.parse::<u64>()
&& id == query_id
{
let yaml_content = fs::read_to_string(&path)
.context(format!("Failed to read {}", path.display()))?;
let metadata: QueryMetadata = serde_yaml::from_str(&yaml_content)
.context(format!("Failed to parse {}", path.display()))?;
let yaml_path = path.display().to_string();
let sql_path = yaml_path.replace(".yaml", ".sql");
if !Path::new(&sql_path).exists() {
bail!("SQL file not found: {sql_path}");
}
let sql = fs::read_to_string(&sql_path)
.context(format!("Failed to read {sql_path}"))?;
return Ok((metadata, sql, yaml_path));
}
}
bail!("Query {query_id} not found in queries/ directory. Run 'stmo-cli fetch {query_id}' first.");
}
fn prompt_for_parameter(param: &Parameter) -> Result<serde_json::Value> {
use dialoguer::{Input, Select};
let title = ¶m.title;
match param.param_type.as_str() {
"date" => {
let input: String = Input::new()
.with_prompt(format!("{title} (YYYY-MM-DD)"))
.interact_text()?;
Ok(serde_json::Value::String(input))
}
"enum" => {
if let Some(enum_options) = ¶m.enum_options {
let options: Vec<&str> = enum_options.lines().collect();
if param.multi_values_options.is_some() {
use dialoguer::MultiSelect;
let selections = MultiSelect::new()
.with_prompt(title)
.items(&options)
.interact()?;
let selected: Vec<String> = selections.iter()
.map(|&i| options[i].to_string())
.collect();
Ok(serde_json::Value::Array(
selected.into_iter().map(serde_json::Value::String).collect()
))
} else {
let selection = Select::new()
.with_prompt(title)
.items(&options)
.default(0)
.interact()?;
Ok(serde_json::Value::String(options[selection].to_string()))
}
} else {
let input: String = Input::new()
.with_prompt(title)
.interact_text()?;
Ok(serde_json::Value::String(input))
}
}
"number" => {
let input: f64 = Input::new()
.with_prompt(title)
.interact_text()?;
Ok(serde_json::json!(input))
}
_ => {
let input: String = Input::new()
.with_prompt(title)
.interact_text()?;
Ok(serde_json::Value::String(input))
}
}
}
fn build_parameter_map(
metadata: &QueryMetadata,
cli_params: &[(String, serde_json::Value)],
interactive: bool,
) -> Result<Option<HashMap<String, serde_json::Value>>> {
if metadata.options.parameters.is_empty() {
return Ok(None);
}
let mut param_map = HashMap::new();
for (name, value) in cli_params {
param_map.insert(name.clone(), value.clone());
}
for param in &metadata.options.parameters {
if !param_map.contains_key(¶m.name) {
if interactive {
eprintln!("\nParameter '{}' required:", param.title);
let value = prompt_for_parameter(param)?;
param_map.insert(param.name.clone(), value);
} else if let Some(default_value) = ¶m.value {
param_map.insert(param.name.clone(), default_value.clone());
} else {
bail!(
"Missing required parameter: '{}' ({}). Use --param {}=value or --interactive",
param.name, param.title, param.name
);
}
}
}
Ok(if param_map.is_empty() { None } else { Some(param_map) })
}
fn format_results_json(result: &crate::models::QueryResult, limit: Option<usize>) -> Result<String> {
let rows = if let Some(limit) = limit {
result.data.rows.iter().take(limit).cloned().collect::<Vec<_>>()
} else {
result.data.rows.clone()
};
serde_json::to_string_pretty(&rows)
.context("Failed to format results as JSON")
}
fn format_results_table(result: &crate::models::QueryResult, limit: Option<usize>) -> String {
use std::fmt::Write;
let mut output = String::new();
let _ = writeln!(output);
for col in &result.data.columns {
let _ = write!(output, "{:20} ", col.name);
}
let _ = writeln!(output);
let _ = writeln!(output, "{}", "-".repeat(result.data.columns.len() * 21));
let rows_to_show = limit.unwrap_or(result.data.rows.len()).min(result.data.rows.len());
for row in &result.data.rows[..rows_to_show] {
if let serde_json::Value::Object(obj) = row {
for col in &result.data.columns {
let value = obj.get(&col.name)
.map(|v| match v {
serde_json::Value::Null => "NULL".to_string(),
serde_json::Value::String(s) => s.clone(),
_ => v.to_string(),
})
.unwrap_or_default();
let truncated = if value.len() > 18 {
format!("{}...", &value[..15])
} else {
value
};
let _ = write!(output, "{truncated:20} ");
}
let _ = writeln!(output);
}
}
if rows_to_show < result.data.rows.len() {
let _ = write!(output, "\n... {} more rows (showing {} of {})\n",
result.data.rows.len() - rows_to_show,
rows_to_show,
result.data.rows.len()
);
}
let _ = write!(output, "\n✓ {} rows returned in {:.2}s\n",
result.data.rows.len(), result.runtime);
output
}
pub async fn execute(
client: &RedashClient,
query_id: u64,
param_args: Vec<String>,
format: OutputFormat,
interactive: bool,
timeout_secs: u64,
limit_rows: Option<usize>,
) -> Result<()> {
let (metadata, _sql, yaml_path) = load_query_metadata_by_id(query_id)?;
eprintln!("Executing query: {} - {}", metadata.id, metadata.name);
eprintln!("Source: {yaml_path}\n");
let cli_params: Vec<(String, serde_json::Value)> = param_args
.iter()
.map(|arg| parse_parameter_arg(arg))
.collect::<Result<Vec<_>>>()?;
let parameters = build_parameter_map(&metadata, &cli_params, interactive)?;
if let Some(ref params) = parameters {
eprintln!("Parameters:");
for (name, value) in params {
eprintln!(" {name} = {value}");
}
eprintln!();
}
let result = client
.execute_query_with_polling(query_id, parameters, timeout_secs, 500)
.await?;
match format {
OutputFormat::Json => {
let json = format_results_json(&result, limit_rows)?;
println!("{json}");
}
OutputFormat::Table => {
let table = format_results_table(&result, limit_rows);
println!("{table}");
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::missing_errors_doc)]
mod tests {
use super::*;
use crate::models::{QueryResult, QueryResultData, Column};
#[test]
fn test_parse_parameter_arg_string() {
let result = parse_parameter_arg("name=value").unwrap();
assert_eq!(result.0, "name");
assert_eq!(result.1, serde_json::Value::String("value".to_string()));
}
#[test]
fn test_parse_parameter_arg_json_array() {
let result = parse_parameter_arg("channels=[\"release\",\"beta\"]").unwrap();
assert_eq!(result.0, "channels");
assert_eq!(result.1, serde_json::json!(["release", "beta"]));
}
#[test]
fn test_parse_parameter_arg_number() {
let result = parse_parameter_arg("count=42").unwrap();
assert_eq!(result.0, "count");
assert_eq!(result.1, serde_json::json!(42));
}
#[test]
fn test_parse_parameter_arg_invalid() {
let result = parse_parameter_arg("invalid");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid parameter format"));
}
#[test]
fn test_format_results_json() {
let result = QueryResult {
id: 1,
data: QueryResultData {
columns: vec![
Column { name: "col1".to_string(), type_name: "string".to_string(), friendly_name: None },
Column { name: "col2".to_string(), type_name: "integer".to_string(), friendly_name: None },
],
rows: vec![
serde_json::json!({"col1": "value1", "col2": 123}),
serde_json::json!({"col1": "value2", "col2": 456}),
],
},
runtime: 1.5,
retrieved_at: "2026-01-21T10:00:00".to_string(),
};
let json = format_results_json(&result, None).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let rows = parsed.as_array().unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["col1"], "value1");
assert_eq!(rows[0]["col2"], 123);
}
#[test]
fn test_format_results_json_with_limit() {
let result = QueryResult {
id: 1,
data: QueryResultData {
columns: vec![
Column { name: "col1".to_string(), type_name: "string".to_string(), friendly_name: None },
],
rows: vec![
serde_json::json!({"col1": "row1"}),
serde_json::json!({"col1": "row2"}),
serde_json::json!({"col1": "row3"}),
],
},
runtime: 1.0,
retrieved_at: "2026-01-21T10:00:00".to_string(),
};
let json = format_results_json(&result, Some(2)).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.as_array().unwrap().len(), 2);
}
#[test]
fn test_format_results_table() {
let result = QueryResult {
id: 1,
data: QueryResultData {
columns: vec![
Column { name: "col1".to_string(), type_name: "string".to_string(), friendly_name: None },
Column { name: "col2".to_string(), type_name: "integer".to_string(), friendly_name: None },
],
rows: vec![
serde_json::json!({"col1": "value1", "col2": 123}),
serde_json::json!({"col1": "value2", "col2": 456}),
],
},
runtime: 1.5,
retrieved_at: "2026-01-21T10:00:00".to_string(),
};
let table = format_results_table(&result, None);
assert!(table.contains("col1"));
assert!(table.contains("col2"));
assert!(table.contains("value1"));
assert!(table.contains("value2"));
assert!(table.contains("2 rows returned"));
}
#[test]
fn test_format_results_table_with_limit() {
let result = QueryResult {
id: 1,
data: QueryResultData {
columns: vec![
Column { name: "col1".to_string(), type_name: "string".to_string(), friendly_name: None },
],
rows: vec![
serde_json::json!({"col1": "row1"}),
serde_json::json!({"col1": "row2"}),
serde_json::json!({"col1": "row3"}),
],
},
runtime: 1.0,
retrieved_at: "2026-01-21T10:00:00".to_string(),
};
let table = format_results_table(&result, Some(2));
assert!(table.contains("row1"));
assert!(table.contains("row2"));
assert!(table.contains("... 1 more rows"));
assert!(table.contains("3 rows returned"));
}
#[test]
fn test_format_results_table_truncation() {
let result = QueryResult {
id: 1,
data: QueryResultData {
columns: vec![
Column { name: "col1".to_string(), type_name: "string".to_string(), friendly_name: None },
],
rows: vec![
serde_json::json!({"col1": "this_is_a_very_long_value_that_should_be_truncated"}),
],
},
runtime: 1.0,
retrieved_at: "2026-01-21T10:00:00".to_string(),
};
let table = format_results_table(&result, None);
assert!(table.contains("..."));
}
#[test]
fn test_output_format_from_str() {
assert!(matches!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json));
assert!(matches!("JSON".parse::<OutputFormat>().unwrap(), OutputFormat::Json));
assert!(matches!("table".parse::<OutputFormat>().unwrap(), OutputFormat::Table));
assert!(matches!("TABLE".parse::<OutputFormat>().unwrap(), OutputFormat::Table));
}
#[test]
fn test_output_format_from_str_invalid() {
let result = "csv".parse::<OutputFormat>();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid format"));
}
}