diff --git a/src/command.rs b/src/command.rs index 2c24684..07e4fb4 100644 --- a/src/command.rs +++ b/src/command.rs @@ -83,18 +83,21 @@ pub enum Commands { command: SkillCommands, }, - /// Retrieve a stored query result by ID + /// Retrieve a stored query result by ID, or list recent results Results { - /// Result ID - result_id: String, + /// Result ID (omit to use a subcommand) + result_id: Option, /// Workspace ID (defaults to first workspace from login) - #[arg(long)] + #[arg(long, global = true)] workspace_id: Option, /// Output format #[arg(long, default_value = "table", value_parser = ["table", "json", "csv"])] format: String, + + #[command(subcommand)] + command: Option, }, } @@ -400,6 +403,24 @@ pub enum SkillCommands { Status, } +#[derive(Subcommand)] +pub enum ResultsCommands { + /// List stored query results + List { + /// Maximum number of results (default: 100, max: 1000) + #[arg(long)] + limit: Option, + + /// Pagination offset + #[arg(long)] + offset: Option, + + /// Output format + #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] + format: String, + }, +} + #[derive(Subcommand)] pub enum TablesCommands { /// List all tables in a workspace diff --git a/src/main.rs b/src/main.rs index 2f5deac..7499bda 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ mod workspace; use anstyle::AnsiColor; use clap::{Parser, builder::Styles}; -use command::{AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, DatasetsCommands, SkillCommands, TablesCommands, WorkspaceCommands}; +use command::{AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, DatasetsCommands, ResultsCommands, SkillCommands, TablesCommands, WorkspaceCommands}; #[derive(Parser)] #[command(name = "hotdata", version, about = concat!("HotData CLI - Command line interface for HotData (v", env!("CARGO_PKG_VERSION"), ")"), long_about = None, disable_version_flag = true)] @@ -149,9 +149,22 @@ fn main() { } SkillCommands::Status => skill::status(), }, - Commands::Results { result_id, workspace_id, format } => { + Commands::Results { result_id, workspace_id, format, command } => { let workspace_id = resolve_workspace(workspace_id); - results::get(&result_id, &workspace_id, &format) + match command { + Some(ResultsCommands::List { limit, offset, format }) => { + results::list(&workspace_id, limit, offset, &format) + } + None => { + match result_id { + Some(id) => results::get(&id, &workspace_id, &format), + None => { + eprintln!("error: provide a result ID or use 'results list'"); + std::process::exit(1); + } + } + } + } } }, } diff --git a/src/results.rs b/src/results.rs index 79fe238..91c81fb 100644 --- a/src/results.rs +++ b/src/results.rs @@ -1,5 +1,5 @@ use crate::config; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; #[derive(Deserialize)] @@ -26,6 +26,96 @@ fn value_to_string(v: &Value) -> String { } } +#[derive(Deserialize, Serialize)] +struct ResultEntry { + id: String, + status: String, + created_at: String, +} + +#[derive(Deserialize)] +struct ListResponse { + results: Vec, + count: u64, + has_more: bool, +} + +pub fn list(workspace_id: &str, limit: Option, offset: Option, format: &str) { + let profile_config = match config::load("default") { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + + let api_key = match &profile_config.api_key { + Some(key) if key != "PLACEHOLDER" => key.clone(), + _ => { + eprintln!("error: not authenticated. Run 'hotdata auth login' to log in."); + std::process::exit(1); + } + }; + + let mut url = format!("{}/results", profile_config.api_url); + let mut params = vec![]; + if let Some(l) = limit { params.push(format!("limit={l}")); } + if let Some(o) = offset { params.push(format!("offset={o}")); } + if !params.is_empty() { url = format!("{url}?{}", params.join("&")); } + + let client = reqwest::blocking::Client::new(); + let resp = match client + .get(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Workspace-Id", workspace_id) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + if !resp.status().is_success() { + use crossterm::style::Stylize; + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + std::process::exit(1); + } + + let body: ListResponse = match resp.json() { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + }; + + match format { + "json" => println!("{}", serde_json::to_string_pretty(&body.results).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&body.results).unwrap()), + "table" => { + if body.results.is_empty() { + use crossterm::style::Stylize; + eprintln!("{}", "No results found.".dark_grey()); + } else { + let rows: Vec> = body.results.iter().map(|r| vec![ + r.id.clone(), + r.status.clone(), + crate::util::format_date(&r.created_at), + ]).collect(); + crate::table::print(&["ID", "STATUS", "CREATED AT"], &rows); + } + if body.has_more { + let next = offset.unwrap_or(0) + body.count as u32; + use crossterm::style::Stylize; + eprintln!("{}", format!("showing {} results — use --offset {next} for more", body.count).dark_grey()); + } + } + _ => unreachable!(), + } +} + pub fn get(result_id: &str, workspace_id: &str, format: &str) { let profile_config = match config::load("default") { Ok(c) => c,