From 23f196678728e9ec6daab851830e2c0ca5e43572 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:11:32 -0700 Subject: [PATCH 1/3] chore: Release hotdata-cli version 0.1.14 Bump version and regenerate changelog for indexes workspace list and test coverage. Apply clippy fixes, resolve items-after-test-module in auth and sandbox, and run rustfmt for a clean fmt check. --- CHANGELOG.md | 9 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- skills/hotdata/SKILL.md | 2 +- src/api.rs | 70 ++++-- src/auth.rs | 161 +++++++++----- src/command.rs | 1 - src/config.rs | 92 +++++--- src/connections.rs | 54 +++-- src/connections_new.rs | 82 ++++--- src/context.rs | 32 ++- src/datasets.rs | 112 +++++++--- src/embedding.rs | 21 +- src/indexes.rs | 21 +- src/jobs.rs | 69 ++++-- src/main.rs | 479 ++++++++++++++++++++++++++++------------ src/queries.rs | 77 +++++-- src/query.rs | 76 +++++-- src/results.rs | 25 ++- src/sandbox.rs | 113 ++++++---- src/skill.rs | 38 ++-- src/table.rs | 106 ++++++--- src/tables.rs | 64 ++++-- src/util.rs | 52 +++-- src/workspace.rs | 69 ++++-- 26 files changed, 1258 insertions(+), 573 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55efe72..874d8f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [0.1.14] - 2026-04-27 + +### ๐Ÿš€ Features + +- *(indexes)* Workspace-wide list with filters and parallel fetch + +### ๐Ÿงช Testing + +- Raise coverage for indexes list and get_none_if_not_found ## [0.1.13] - 2026-04-24 ### ๐Ÿš€ Features diff --git a/Cargo.lock b/Cargo.lock index 19ac42e..be15d64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -732,7 +732,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hotdata-cli" -version = "0.1.13" +version = "0.1.14" dependencies = [ "anstyle", "base64", diff --git a/Cargo.toml b/Cargo.toml index e105e69..86f9621 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hotdata-cli" -version = "0.1.13" +version = "0.1.14" edition = "2024" repository = "https://github.com/hotdata-dev/hotdata-cli" description = "CLI tool for Hotdata.dev" diff --git a/README.md b/README.md index e986b3e..d6c86e1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@
Command line interface for Hotdata.

- version + version build coverage

diff --git a/skills/hotdata/SKILL.md b/skills/hotdata/SKILL.md index 4f9b010..576a748 100644 --- a/skills/hotdata/SKILL.md +++ b/skills/hotdata/SKILL.md @@ -1,7 +1,7 @@ --- name: hotdata description: Use this skill when the user wants to run hotdata CLI commands, query the Hotdata API, list workspaces, list connections, create connections, list tables, manage datasets, execute SQL queries, inspect query run history, search tables, manage indexes, manage sandboxes, manage workspace context and stored docs such as context:DATAMODEL via the context API (`hotdata context`), or interact with the hotdata service. Activate when the user says "run hotdata", "query hotdata", "list workspaces", "list connections", "create a connection", "list tables", "list datasets", "create a dataset", "upload a dataset", "execute a query", "search a table", "list indexes", "create an index", "list query runs", "list past queries", "query history", "list sandboxes", "create a sandbox", "run a sandbox", "workspace context", "pull context", "push context", "data model", "context:DATAMODEL", or asks you to use the hotdata CLI. -version: 0.1.13 +version: 0.1.14 --- # Hotdata CLI Skill diff --git a/src/api.rs b/src/api.rs index 567f339..5e0d9fa 100644 --- a/src/api.rs +++ b/src/api.rs @@ -28,7 +28,9 @@ impl ApiClient { let api_key = match &profile_config.api_key { Some(key) if key != "PLACEHOLDER" => key.clone(), _ => { - eprintln!("error: not authenticated. Run 'hotdata auth login' (or 'hotdata auth') to log in."); + eprintln!( + "error: not authenticated. Run 'hotdata auth login' (or 'hotdata auth') to log in." + ); std::process::exit(1); } }; @@ -62,7 +64,7 @@ impl ApiClient { fn debug_headers(&self) -> Vec<(&str, String)> { let masked = if self.api_key.len() > 4 { - format!("Bearer ...{}", &self.api_key[self.api_key.len()-4..]) + format!("Bearer ...{}", &self.api_key[self.api_key.len() - 4..]) } else { "Bearer ***".to_string() }; @@ -80,7 +82,8 @@ impl ApiClient { fn log_request(&self, method: &str, url: &str, body: Option<&serde_json::Value>) { let headers = self.debug_headers(); - let header_refs: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let header_refs: Vec<(&str, &str)> = + headers.iter().map(|(k, v)| (*k, v.as_str())).collect(); util::debug_request(method, url, &header_refs, body); } @@ -89,16 +92,27 @@ impl ApiClient { /// instead of whatever cryptic body the primary endpoint returned. fn fail_response(&self, status: reqwest::StatusCode, body: String) -> ! { let auth_status = if status.is_client_error() { - config::load("default").ok().map(|pc| auth::check_status(&pc)) + config::load("default") + .ok() + .map(|pc| auth::check_status(&pc)) } else { None }; - eprintln!("{}", format_fail_message(status, &body, auth_status.as_ref()).red()); + eprintln!( + "{}", + format_fail_message(status, &body, auth_status.as_ref()).red() + ); std::process::exit(1); } - fn build_request(&self, method: reqwest::Method, url: &str) -> reqwest::blocking::RequestBuilder { - let mut req = self.client.request(method, url) + fn build_request( + &self, + method: reqwest::Method, + url: &str, + ) -> reqwest::blocking::RequestBuilder { + let mut req = self + .client + .request(method, url) .header("Authorization", format!("Bearer {}", self.api_key)); if let Some(ref ws) = self.workspace_id { req = req.header("X-Workspace-Id", ws); @@ -113,14 +127,23 @@ impl ApiClient { /// GET request with query parameters, returns parsed response. /// Parameters with `None` values are omitted. - pub fn get_with_params(&self, path: &str, params: &[(&str, Option)]) -> T { - let filtered: Vec<(&str, &String)> = params.iter() + pub fn get_with_params( + &self, + path: &str, + params: &[(&str, Option)], + ) -> T { + let filtered: Vec<(&str, &String)> = params + .iter() .filter_map(|(k, v)| v.as_ref().map(|val| (*k, val))) .collect(); let url = format!("{}{path}", self.api_url); self.log_request("GET", &url, None); - let resp = match self.build_request(reqwest::Method::GET, &url).query(&filtered).send() { + let resp = match self + .build_request(reqwest::Method::GET, &url) + .query(&filtered) + .send() + { Ok(r) => r, Err(e) => { eprintln!("error connecting to API: {e}"); @@ -205,7 +228,8 @@ impl ApiClient { let url = format!("{}{path}", self.api_url); self.log_request("POST", &url, Some(body)); - let resp = match self.build_request(reqwest::Method::POST, &url) + let resp = match self + .build_request(reqwest::Method::POST, &url) .json(body) .send() { @@ -253,7 +277,8 @@ impl ApiClient { let url = format!("{}{path}", self.api_url); self.log_request("POST", &url, Some(body)); - let resp = match self.build_request(reqwest::Method::POST, &url) + let resp = match self + .build_request(reqwest::Method::POST, &url) .json(body) .send() { @@ -272,7 +297,8 @@ impl ApiClient { let url = format!("{}{path}", self.api_url); self.log_request("PATCH", &url, Some(body)); - let resp = match self.build_request(reqwest::Method::PATCH, &url) + let resp = match self + .build_request(reqwest::Method::PATCH, &url) .json(body) .send() { @@ -308,7 +334,8 @@ impl ApiClient { let url = format!("{}{path}", self.api_url); self.log_request("POST", &url, None); - let mut req = self.build_request(reqwest::Method::POST, &url) + let mut req = self + .build_request(reqwest::Method::POST, &url) .header("Content-Type", content_type); if let Some(len) = content_length { @@ -325,7 +352,6 @@ impl ApiClient { util::debug_response(resp) } - } /// Decide what error text to print for a failed response. Pulled out as a pure @@ -336,10 +362,10 @@ fn format_fail_message( body: &str, auth_status: Option<&auth::AuthStatus>, ) -> String { - if status.is_client_error() { - if let Some(auth::AuthStatus::Invalid(_)) = auth_status { - return "error: API key is invalid. Run 'hotdata auth login' (or 'hotdata auth') to re-authenticate.".to_string(); - } + if status.is_client_error() + && let Some(auth::AuthStatus::Invalid(_)) = auth_status + { + return "error: API key is invalid. Run 'hotdata auth login' (or 'hotdata auth') to re-authenticate.".to_string(); } util::api_error(body.to_string()) } @@ -465,11 +491,7 @@ mod tests { fn format_fail_message_4xx_no_probe_result_falls_through() { // Caller couldn't load config (None) โ€” still surface the upstream error. let body = "plain body"; - let msg = format_fail_message( - reqwest::StatusCode::NOT_FOUND, - body, - None, - ); + let msg = format_fail_message(reqwest::StatusCode::NOT_FOUND, body, None); assert!(!msg.contains("API key is invalid")); assert_eq!(msg, "plain body"); } diff --git a/src/auth.rs b/src/auth.rs index 25483d5..d84d095 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -70,8 +70,20 @@ pub fn status(profile: &str) { print_row("API Key", &format!("{}{source_label}", "Valid".green())); match profile_config.workspaces.first() { Some(w) => { - print_row("Workspace", &format!("{} {}", w.name.as_str().cyan(), format!("({})", w.public_id).dark_grey())); - print_row("", &"use 'hotdata workspaces set' to switch workspaces".dark_grey().to_string()); + print_row( + "Workspace", + &format!( + "{} {}", + w.name.as_str().cyan(), + format!("({})", w.public_id).dark_grey() + ), + ); + print_row( + "", + &"use 'hotdata workspaces set' to switch workspaces" + .dark_grey() + .to_string(), + ); } None => print_row("Current Workspace", &"None".dark_grey().to_string()), } @@ -81,10 +93,7 @@ pub fn status(profile: &str) { print_row("Authenticated", &"No".red().to_string()); print_row( "API Key", - &format!( - "{}{source_label}", - format!("Invalid (HTTP {})", code).red() - ), + &format!("{}{source_label}", format!("Invalid (HTTP {})", code).red()), ); } AuthStatus::ConnectionError(e) => { @@ -96,7 +105,10 @@ pub fn status(profile: &str) { #[derive(Debug, PartialEq)] pub enum LoginResult { - Success { token: String, workspace: Option }, + Success { + token: String, + workspace: Option, + }, Forbidden, Failed(String), ConnectionError(String), @@ -108,10 +120,15 @@ struct TokenResponse { } #[derive(Deserialize)] -struct WsListResponse { workspaces: Vec } +struct WsListResponse { + workspaces: Vec, +} #[derive(Deserialize)] -struct WsItem { public_id: String, name: String } +struct WsItem { + public_id: String, + name: String, +} /// Exchange an authorization code + PKCE verifier for an API token, /// then fetch available workspaces. @@ -148,30 +165,52 @@ fn exchange_and_save_token(api_url: &str, code: &str, code_verifier: &str) -> Lo // Fetch and cache workspaces let ws_url = format!("{api_url}/workspaces"); - let default_workspace = if let Ok(r) = client.get(&ws_url).header("Authorization", format!("Bearer {}", body.token)).send() { + let default_workspace = if let Ok(r) = client + .get(&ws_url) + .header("Authorization", format!("Bearer {}", body.token)) + .send() + { if r.status().is_success() { if let Ok(ws) = r.json::() { - let entries: Vec = ws.workspaces.into_iter() - .map(|w| config::WorkspaceEntry { public_id: w.public_id, name: w.name }) + let entries: Vec = ws + .workspaces + .into_iter() + .map(|w| config::WorkspaceEntry { + public_id: w.public_id, + name: w.name, + }) .collect(); let first = entries.first().cloned(); let _ = config::save_workspaces("default", entries); first - } else { None } - } else { None } - } else { None }; + } else { + None + } + } else { + None + } + } else { + None + }; - LoginResult::Success { token: body.token, workspace: default_workspace } + LoginResult::Success { + token: body.token, + workspace: default_workspace, + } } /// Wait for the browser callback, verify state, and extract the authorization code. fn receive_callback(server: &tiny_http::Server, expected_state: &str) -> Result { - let request = server.recv().map_err(|e| format!("failed to receive callback: {e}"))?; + let request = server + .recv() + .map_err(|e| format!("failed to receive callback: {e}"))?; let raw_url = request.url().to_string(); let params = parse_query_params(&raw_url); if params.get("state").map(String::as_str) != Some(expected_state) { - let _ = request.respond(tiny_http::Response::from_string("Login failed: state mismatch")); + let _ = request.respond(tiny_http::Response::from_string( + "Login failed: state mismatch", + )); return Err("state mismatch โ€” possible CSRF attack".into()); } @@ -318,14 +357,29 @@ pub fn login() { match workspace { Some(w) => { - print_row("Workspace", &format!("{} {}", w.name.as_str().cyan(), format!("({})", w.public_id).dark_grey())); - print_row("", &"use 'hotdata workspaces set' to switch workspaces".dark_grey().to_string()); + print_row( + "Workspace", + &format!( + "{} {}", + w.name.as_str().cyan(), + format!("({})", w.public_id).dark_grey() + ), + ); + print_row( + "", + &"use 'hotdata workspaces set' to switch workspaces" + .dark_grey() + .to_string(), + ); } None => print_row("Workspace", &"None".dark_grey().to_string()), } } LoginResult::Forbidden => { - eprintln!("{}", "You are not authorized to create a new API token.".red()); + eprintln!( + "{}", + "You are not authorized to create a new API token.".red() + ); std::process::exit(1); } LoginResult::Failed(msg) => { @@ -356,8 +410,8 @@ fn generate_code_challenge(verifier: &str) -> String { } fn parse_query_params(url: &str) -> HashMap { - url.splitn(2, '?') - .nth(1) + url.split_once('?') + .map(|(_, q)| q) .unwrap_or("") .split('&') .filter_map(|pair| { @@ -367,6 +421,25 @@ fn parse_query_params(url: &str) -> HashMap { .collect() } +fn print_row(label: &str, value: &str) { + stdout() + .execute(SetForegroundColor(Color::DarkGrey)) + .unwrap() + .execute(Print(format!( + "{:<16}", + if label.is_empty() { + String::new() + } else { + format!("{label}:") + } + ))) + .unwrap() + .execute(ResetColor) + .unwrap() + .execute(Print(format!("{value}\n"))) + .unwrap(); +} + #[cfg(test)] mod tests { use super::*; @@ -412,10 +485,7 @@ mod tests { #[test] fn status_invalid_with_bad_key() { let mut server = mockito::Server::new(); - let mock = server - .mock("GET", "/workspaces") - .with_status(401) - .create(); + let mock = server.mock("GET", "/workspaces").with_status(401).create(); let profile = mock_profile(&server.url(), Some("bad-key")); assert_eq!(check_status(&profile), AuthStatus::Invalid(401)); @@ -425,10 +495,7 @@ mod tests { #[test] fn status_invalid_with_forbidden() { let mut server = mockito::Server::new(); - let mock = server - .mock("GET", "/workspaces") - .with_status(403) - .create(); + let mock = server.mock("GET", "/workspaces").with_status(403).create(); let profile = mock_profile(&server.url(), Some("forbidden-key")); assert_eq!(check_status(&profile), AuthStatus::Invalid(403)); @@ -470,10 +537,7 @@ mod tests { #[test] fn not_signed_in_when_key_invalid() { let mut server = mockito::Server::new(); - let mock = server - .mock("GET", "/workspaces") - .with_status(401) - .create(); + let mock = server.mock("GET", "/workspaces").with_status(401).create(); let profile = mock_profile(&server.url(), Some("expired-key")); assert!(!is_already_signed_in(&profile)); @@ -560,10 +624,7 @@ mod tests { let (_tmp, _guard) = with_temp_config_dir(); let mut server = mockito::Server::new(); - let mock = server - .mock("POST", "/auth/token") - .with_status(403) - .create(); + let mock = server.mock("POST", "/auth/token").with_status(403).create(); let result = exchange_and_save_token(&server.url(), "code", "verifier"); mock.assert(); @@ -575,10 +636,7 @@ mod tests { let (_tmp, _guard) = with_temp_config_dir(); let mut server = mockito::Server::new(); - let mock = server - .mock("POST", "/auth/token") - .with_status(401) - .create(); + let mock = server.mock("POST", "/auth/token").with_status(401).create(); let result = exchange_and_save_token(&server.url(), "code", "verifier"); mock.assert(); @@ -593,10 +651,7 @@ mod tests { let (_tmp, _guard) = with_temp_config_dir(); let mut server = mockito::Server::new(); - let mock = server - .mock("POST", "/auth/token") - .with_status(500) - .create(); + let mock = server.mock("POST", "/auth/token").with_status(500).create(); let result = exchange_and_save_token(&server.url(), "code", "verifier"); mock.assert(); @@ -683,15 +738,3 @@ mod tests { assert!(result.unwrap_err().contains("no authorization code")); } } - -fn print_row(label: &str, value: &str) { - stdout() - .execute(SetForegroundColor(Color::DarkGrey)) - .unwrap() - .execute(Print(format!("{:<16}", if label.is_empty() { String::new() } else { format!("{label}:") }))) - .unwrap() - .execute(ResetColor) - .unwrap() - .execute(Print(format!("{value}\n"))) - .unwrap(); -} diff --git a/src/command.rs b/src/command.rs index 3d1cd23..d284ec7 100644 --- a/src/command.rs +++ b/src/command.rs @@ -386,7 +386,6 @@ pub enum DatasetsCommands { }, } - #[derive(Subcommand)] pub enum WorkspaceCommands { /// List all workspaces diff --git a/src/config.rs b/src/config.rs index d4355f9..8a9533a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,15 +30,9 @@ pub struct WorkspaceEntry { pub name: String, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Default, Serialize)] pub struct AppUrl(Option); -impl Default for AppUrl { - fn default() -> Self { - AppUrl(None) - } -} - impl Deref for AppUrl { type Target = str; @@ -67,15 +61,9 @@ pub enum ApiKeySource { Flag, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Default, Serialize)] pub struct ApiUrl(pub(crate) Option); -impl Default for ApiUrl { - fn default() -> Self { - ApiUrl(None) - } -} - impl Deref for ApiUrl { type Target = str; @@ -155,8 +143,8 @@ pub fn remove_api_key(profile: &str) -> Result<(), String> { return Ok(()); } - let content = fs::read_to_string(&config_path) - .map_err(|e| format!("error reading config file: {e}"))?; + let content = + fs::read_to_string(&config_path).map_err(|e| format!("error reading config file: {e}"))?; let mut config_file: ConfigFile = serde_yaml::from_str(&content).map_err(|e| format!("error parsing config file: {e}"))?; @@ -203,11 +191,15 @@ pub fn save_default_workspace(profile: &str, workspace: WorkspaceEntry) -> Resul .map_err(|e| format!("error reading config file: {e}"))?; serde_yaml::from_str(&content).map_err(|e| format!("error parsing config file: {e}"))? } else { - ConfigFile { profiles: HashMap::new() } + ConfigFile { + profiles: HashMap::new(), + } }; let entry = config_file.profiles.entry(profile.to_string()).or_default(); - entry.workspaces.retain(|w| w.public_id != workspace.public_id); + entry + .workspaces + .retain(|w| w.public_id != workspace.public_id); entry.workspaces.insert(0, workspace); let content = serde_yaml::to_string(&config_file) @@ -223,7 +215,9 @@ pub fn save_sandbox(profile: &str, sandbox_id: &str) -> Result<(), String> { .map_err(|e| format!("error reading config file: {e}"))?; serde_yaml::from_str(&content).map_err(|e| format!("error parsing config file: {e}"))? } else { - ConfigFile { profiles: HashMap::new() } + ConfigFile { + profiles: HashMap::new(), + } }; config_file @@ -244,8 +238,8 @@ pub fn clear_sandbox(profile: &str) -> Result<(), String> { return Ok(()); } - let content = fs::read_to_string(&config_path) - .map_err(|e| format!("error reading config file: {e}"))?; + let content = + fs::read_to_string(&config_path).map_err(|e| format!("error reading config file: {e}"))?; let mut config_file: ConfigFile = serde_yaml::from_str(&content).map_err(|e| format!("error parsing config file: {e}"))?; @@ -258,7 +252,10 @@ pub fn clear_sandbox(profile: &str) -> Result<(), String> { write_config(&config_path, &content) } -pub fn resolve_workspace_id(provided: Option, profile_config: &ProfileConfig) -> Result { +pub fn resolve_workspace_id( + provided: Option, + profile_config: &ProfileConfig, +) -> Result { if let Some(id) = provided { return Ok(id); } @@ -281,14 +278,20 @@ pub fn load(profile: &str) -> Result { let config_file = config_path()?; let mut profile_config = if config_file.exists() { - let content = - fs::read_to_string(&config_file).map_err(|e| format!("error reading config file: {e}"))?; + let content = fs::read_to_string(&config_file) + .map_err(|e| format!("error reading config file: {e}"))?; let config_file: ConfigFile = serde_yaml::from_str(&content).unwrap_or_else(|_| { eprintln!("{}", "error parsing config file.".red()); - eprintln!("Run 'hotdata auth login' (or 'hotdata auth') to generate a new config file."); + eprintln!( + "Run 'hotdata auth login' (or 'hotdata auth') to generate a new config file." + ); std::process::exit(1); }); - config_file.profiles.get(profile).cloned().unwrap_or_default() + config_file + .profiles + .get(profile) + .cloned() + .unwrap_or_default() } else { ProfileConfig::default() }; @@ -336,8 +339,8 @@ pub mod test_helpers { #[cfg(test)] mod tests { - use super::*; use super::test_helpers::with_temp_config_dir; + use super::*; #[test] fn save_and_load_api_key() { @@ -395,8 +398,14 @@ mod tests { save_api_key("default", "key").unwrap(); let workspaces = vec![ - WorkspaceEntry { public_id: "ws-1".into(), name: "First".into() }, - WorkspaceEntry { public_id: "ws-2".into(), name: "Second".into() }, + WorkspaceEntry { + public_id: "ws-1".into(), + name: "First".into(), + }, + WorkspaceEntry { + public_id: "ws-2".into(), + name: "Second".into(), + }, ]; save_workspaces("default", workspaces).unwrap(); @@ -412,15 +421,24 @@ mod tests { save_api_key("default", "key").unwrap(); let workspaces = vec![ - WorkspaceEntry { public_id: "ws-1".into(), name: "First".into() }, - WorkspaceEntry { public_id: "ws-2".into(), name: "Second".into() }, + WorkspaceEntry { + public_id: "ws-1".into(), + name: "First".into(), + }, + WorkspaceEntry { + public_id: "ws-2".into(), + name: "Second".into(), + }, ]; save_workspaces("default", workspaces).unwrap(); // Set ws-2 as default โ€” should move to front save_default_workspace( "default", - WorkspaceEntry { public_id: "ws-2".into(), name: "Second".into() }, + WorkspaceEntry { + public_id: "ws-2".into(), + name: "Second".into(), + }, ) .unwrap(); @@ -464,7 +482,10 @@ mod tests { #[test] fn resolve_workspace_id_prefers_provided() { let profile = ProfileConfig { - workspaces: vec![WorkspaceEntry { public_id: "ws-1".into(), name: "WS".into() }], + workspaces: vec![WorkspaceEntry { + public_id: "ws-1".into(), + name: "WS".into(), + }], ..Default::default() }; let result = resolve_workspace_id(Some("explicit-id".into()), &profile).unwrap(); @@ -474,7 +495,10 @@ mod tests { #[test] fn resolve_workspace_id_falls_back_to_first() { let profile = ProfileConfig { - workspaces: vec![WorkspaceEntry { public_id: "ws-1".into(), name: "WS".into() }], + workspaces: vec![WorkspaceEntry { + public_id: "ws-1".into(), + name: "WS".into(), + }], ..Default::default() }; let result = resolve_workspace_id(None, &profile).unwrap(); diff --git a/src/connections.rs b/src/connections.rs index 69fcc2d..9909cf0 100644 --- a/src/connections.rs +++ b/src/connections.rs @@ -37,7 +37,9 @@ impl Serialize for HealthStatus { fn fetch_health(api: &ApiClient, connection_id: &str, show_spinner: bool) -> HealthStatus { let spinner = show_spinner.then(|| crate::util::spinner("Checking connection health...")); let (status, body) = api.get_raw(&format!("/connections/{connection_id}/health")); - if let Some(s) = spinner { s.finish_and_clear(); } + if let Some(s) = spinner { + s.finish_and_clear(); + } if !status.is_success() { return HealthStatus::Unavailable(crate::util::api_error(body)); @@ -89,14 +91,19 @@ pub fn types_list(workspace_id: &str, format: &str) { let body: ListConnectionTypesResponse = api.get("/connection-types"); match format { - "json" => println!("{}", serde_json::to_string_pretty(&body.connection_types).unwrap()), + "json" => println!( + "{}", + serde_json::to_string_pretty(&body.connection_types).unwrap() + ), "yaml" => print!("{}", serde_yaml::to_string(&body.connection_types).unwrap()), "table" => { if body.connection_types.is_empty() { use crossterm::style::Stylize; eprintln!("{}", "No connection types found.".dark_grey()); } else { - let rows: Vec> = body.connection_types.iter() + let rows: Vec> = body + .connection_types + .iter() .map(|ct| vec![ct.name.clone(), ct.label.clone()]) .collect(); crate::table::print(&["NAME", "LABEL"], &rows); @@ -156,7 +163,9 @@ pub fn get(workspace_id: &str, connection_id: &str, format: &str) { let spinner = is_table.then(|| crate::util::spinner("Fetching connection...")); let detail: ConnectionDetail = api.get(&format!("/connections/{connection_id}")); - if let Some(s) = spinner { s.finish_and_clear(); } + if let Some(s) = spinner { + s.finish_and_clear(); + } let health = fetch_health(&api, connection_id, is_table); @@ -189,7 +198,12 @@ pub fn get(workspace_id: &str, connection_id: &str, format: &str) { println!("{}{}", label("id:"), detail.id.dark_cyan()); println!("{}{}", label("name:"), detail.name.white()); println!("{}{}", label("source_type:"), detail.source_type.green()); - println!("{}{}", label("tables:"), format!("{} synced / {} total", detail.synced_table_count.to_string().cyan(), detail.table_count.to_string().cyan())); + println!( + "{}{} synced / {} total", + label("tables:"), + detail.synced_table_count.to_string().cyan(), + detail.table_count.to_string().cyan(), + ); println!("{}{}", label("health:"), format_health(&health)); } _ => unreachable!(), @@ -206,13 +220,7 @@ struct CreateResponse { discovery_error: Option, } -pub fn create( - workspace_id: &str, - name: &str, - source_type: &str, - config: &str, - format: &str, -) { +pub fn create(workspace_id: &str, name: &str, source_type: &str, config: &str, format: &str) { let config_value: serde_json::Value = match serde_json::from_str(config) { Ok(v) => v, Err(e) => { @@ -232,7 +240,9 @@ pub fn create( let spinner = is_table.then(|| crate::util::spinner("Creating connection...")); let (status, resp_body) = api.post_raw("/connections", &body); - if let Some(s) = &spinner { s.finish_and_clear(); } + if let Some(s) = &spinner { + s.finish_and_clear(); + } if !status.is_success() { use crossterm::style::Stylize; @@ -284,8 +294,13 @@ pub fn create( println!("tables_discovered: {}", result.tables_discovered); let status_colored = match result.discovery_status.as_str() { "success" => result.discovery_status.green().to_string(), - "failed" => result.discovery_error.as_deref().unwrap_or("failed").red().to_string(), - _ => result.discovery_status.yellow().to_string(), + "failed" => result + .discovery_error + .as_deref() + .unwrap_or("failed") + .red() + .to_string(), + _ => result.discovery_status.yellow().to_string(), }; println!("discovery_status: {status_colored}"); println!("health: {}", format_health(&health)); @@ -304,7 +319,10 @@ pub fn list(workspace_id: &str, format: &str) { match format { "json" => { - println!("{}", serde_json::to_string_pretty(&body.connections).unwrap()); + println!( + "{}", + serde_json::to_string_pretty(&body.connections).unwrap() + ); } "yaml" => { print!("{}", serde_yaml::to_string(&body.connections).unwrap()); @@ -314,7 +332,9 @@ pub fn list(workspace_id: &str, format: &str) { use crossterm::style::Stylize; eprintln!("{}", "No connections found.".dark_grey()); } else { - let rows: Vec> = body.connections.iter() + let rows: Vec> = body + .connections + .iter() .map(|c| vec![c.id.clone(), c.name.clone(), c.source_type.clone()]) .collect(); crate::table::print(&["ID", "NAME", "SOURCE TYPE"], &rows); diff --git a/src/connections_new.rs b/src/connections_new.rs index 042d188..da8a5ef 100644 --- a/src/connections_new.rs +++ b/src/connections_new.rs @@ -1,5 +1,5 @@ -use inquire::{Confirm, Password, Select, Text}; use inquire::validator::Validation; +use inquire::{Confirm, Password, Select, Text}; use serde_json::{Map, Number, Value}; use crate::api::ApiClient; @@ -34,8 +34,16 @@ fn fetch_types(api: &ApiClient) -> Vec { fn fetch_detail(api: &ApiClient, name: &str) -> ConnectionTypeDetail { let body: Value = api.get(&format!("/connection-types/{name}")); ConnectionTypeDetail { - config_schema: if body["config_schema"].is_null() { None } else { Some(body["config_schema"].clone()) }, - auth: if body["auth"].is_null() { None } else { Some(body["auth"].clone()) }, + config_schema: if body["config_schema"].is_null() { + None + } else { + Some(body["config_schema"].clone()) + }, + auth: if body["auth"].is_null() { + None + } else { + Some(body["auth"].clone()) + }, } } @@ -49,7 +57,9 @@ fn walk_properties(schema: &Value) -> Map { .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) .unwrap_or_default(); - let Some(props) = schema["properties"].as_object() else { return out }; + let Some(props) = schema["properties"].as_object() else { + return out; + }; for (key, field) in props { let is_required = required.contains(&key.as_str()); @@ -68,7 +78,9 @@ fn walk_variant(schema: &Value) -> Map { .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) .unwrap_or_default(); - let Some(props) = schema["properties"].as_object() else { return out }; + let Some(props) = schema["properties"].as_object() else { + return out; + }; for (key, field) in props { // Auto-inject const fields without prompting @@ -111,7 +123,11 @@ fn prompt_field(key: &str, field: &Value, is_required: bool) -> Option { p = p.with_help_message(opt_hint); } let val = p.prompt().unwrap_or_else(|_| std::process::exit(0)); - if val.is_empty() && !is_required { None } else { Some(Value::String(val)) } + if val.is_empty() && !is_required { + None + } else { + Some(Value::String(val)) + } } ("string", _) => { @@ -124,25 +140,28 @@ fn prompt_field(key: &str, field: &Value, is_required: bool) -> Option { t = t.with_help_message(opt_hint); } let val = t.prompt().unwrap_or_else(|_| std::process::exit(0)); - if val.is_empty() && !is_required { None } else { Some(Value::String(val)) } + if val.is_empty() && !is_required { + None + } else { + Some(Value::String(val)) + } } ("integer", _) => { let label = format!("{key}:"); - let t = Text::new(&label) - .with_validator(move |input: &str| { - if input.is_empty() { - if is_required { - return Ok(Validation::Invalid("This field is required".into())); - } - return Ok(Validation::Valid); - } - if input.parse::().is_ok() { - Ok(Validation::Valid) - } else { - Ok(Validation::Invalid("Must be a whole number".into())) + let t = Text::new(&label).with_validator(move |input: &str| { + if input.is_empty() { + if is_required { + return Ok(Validation::Invalid("This field is required".into())); } - }); + return Ok(Validation::Valid); + } + if input.parse::().is_ok() { + Ok(Validation::Valid) + } else { + Ok(Validation::Invalid("Must be a whole number".into())) + } + }); let help_t; let t = if !is_required { help_t = t.with_help_message(opt_hint); @@ -154,7 +173,9 @@ fn prompt_field(key: &str, field: &Value, is_required: bool) -> Option { if val.is_empty() && !is_required { None } else { - val.parse::().ok().map(|n| Value::Number(Number::from(n))) + val.parse::() + .ok() + .map(|n| Value::Number(Number::from(n))) } } @@ -223,13 +244,19 @@ pub fn run(workspace_id: &str) { eprintln!("error: no connection types available"); std::process::exit(1); } - let displays: Vec = types.iter().map(|t| format!("{} ({})", t.label, t.name)).collect(); + let displays: Vec = types + .iter() + .map(|t| format!("{} ({})", t.label, t.name)) + .collect(); let names: Vec = types.iter().map(|t| t.name.clone()).collect(); let selected_display = Select::new("Connection type:", displays.clone()) .prompt() .unwrap_or_else(|_| std::process::exit(0)); - let idx = displays.iter().position(|d| d == &selected_display).unwrap(); + let idx = displays + .iter() + .position(|d| d == &selected_display) + .unwrap(); let source_type = &names[idx]; // Phase 2: Fetch schema for selected type @@ -320,8 +347,13 @@ pub fn run(workspace_id: &str) { println!("tables_discovered: {}", result.tables_discovered); let status = match result.discovery_status.as_str() { "success" => result.discovery_status.green().to_string(), - "failed" => result.discovery_error.as_deref().unwrap_or("failed").red().to_string(), - _ => result.discovery_status.yellow().to_string(), + "failed" => result + .discovery_error + .as_deref() + .unwrap_or("failed") + .red() + .to_string(), + _ => result.discovery_status.yellow().to_string(), }; println!("discovery_status: {status}"); let health_str = match &health { diff --git a/src/context.rs b/src/context.rs index 92cc68a..2a38650 100644 --- a/src/context.rs +++ b/src/context.rs @@ -89,12 +89,13 @@ pub fn validate_context_stem(name: &str) -> Result<(), String> { } let mut chars = name.chars(); - if let Some(first) = chars.next() { - if !first.is_ascii_alphabetic() && first != '_' { - return Err(format!( - "name must start with a letter or underscore, got '{first}'" - )); - } + if let Some(first) = chars.next() + && !first.is_ascii_alphabetic() + && first != '_' + { + return Err(format!( + "name must start with a letter or underscore, got '{first}'" + )); } for c in chars { @@ -121,7 +122,10 @@ fn local_md_path(name: &str) -> PathBuf { .join(format!("{name}.md")) } -fn fetch_context(api: &ApiClient, name: &str) -> Result { +fn fetch_context( + api: &ApiClient, + name: &str, +) -> Result { let path = format!("/context/{name}"); let (status, body) = api.get_raw(&path); if status == reqwest::StatusCode::NOT_FOUND { @@ -215,7 +219,11 @@ pub fn pull(workspace_id: &str, name: &str, force: bool, dry_run: bool) { if !dry_run && !force && path.exists() { eprintln!( "{}", - format!("error: {} already exists (use --force to overwrite)", path.display()).red() + format!( + "error: {} already exists (use --force to overwrite)", + path.display() + ) + .red() ); std::process::exit(1); } @@ -253,8 +261,12 @@ pub fn pull(workspace_id: &str, name: &str, force: bool, dry_run: bool) { println!( "{}", - format!("wrote {} (updated {})", path.display(), crate::util::format_date(&ctx.updated_at)) - .green() + format!( + "wrote {} (updated {})", + path.display(), + crate::util::format_date(&ctx.updated_at) + ) + .green() ); } diff --git a/src/datasets.rs b/src/datasets.rs index 3f7b56d..3d51778 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -61,20 +61,38 @@ struct FileType { fn detect_from_bytes(bytes: &[u8]) -> FileType { if bytes.starts_with(b"PAR1") { - return FileType { content_type: "application/octet-stream", format: "parquet" }; + return FileType { + content_type: "application/octet-stream", + format: "parquet", + }; } let first = bytes.iter().find(|&&b| !b.is_ascii_whitespace()).copied(); if matches!(first, Some(b'{') | Some(b'[')) { - return FileType { content_type: "application/json", format: "json" }; + return FileType { + content_type: "application/json", + format: "json", + }; + } + FileType { + content_type: "text/csv", + format: "csv", } - FileType { content_type: "text/csv", format: "csv" } } fn detect_from_path(path: &str) -> Option { match Path::new(path).extension().and_then(|e| e.to_str()) { - Some("csv") => Some(FileType { content_type: "text/csv", format: "csv" }), - Some("json") => Some(FileType { content_type: "application/json", format: "json" }), - Some("parquet") => Some(FileType { content_type: "application/octet-stream", format: "parquet" }), + Some("csv") => Some(FileType { + content_type: "text/csv", + format: "csv", + }), + Some("json") => Some(FileType { + content_type: "application/json", + format: "json", + }), + Some("parquet") => Some(FileType { + content_type: "application/octet-stream", + format: "parquet", + }), _ => None, } } @@ -90,8 +108,8 @@ fn stdin_redirect_filename() -> Option { } #[cfg(target_os = "macos")] { + use nix::fcntl::{FcntlArg, fcntl}; use std::os::unix::io::AsRawFd; - use nix::fcntl::{fcntl, FcntlArg}; let fd = std::io::stdin().as_raw_fd(); let mut path = std::path::PathBuf::new(); match fcntl(fd, FcntlArg::F_GETPATH(&mut path)) { @@ -105,7 +123,6 @@ fn stdin_redirect_filename() -> Option { } } - fn make_progress_bar(total: u64) -> ProgressBar { let pb = ProgressBar::new(total); pb.set_style( @@ -153,10 +170,7 @@ fn do_upload( } // Returns (upload_id, format) -fn upload_from_file( - api: &ApiClient, - path: &str, -) -> (String, &'static str) { +fn upload_from_file(api: &ApiClient, path: &str) -> (String, &'static str) { let mut f = match std::fs::File::open(path) { Ok(f) => f, Err(e) => { @@ -182,9 +196,7 @@ fn upload_from_file( } // Returns (upload_id, format) -fn upload_from_stdin( - api: &ApiClient, -) -> (String, &'static str) { +fn upload_from_stdin(api: &ApiClient) -> (String, &'static str) { use std::io::Read; let mut probe = [0u8; 512]; let n = std::io::stdin().read(&mut probe).unwrap_or(0); @@ -194,8 +206,7 @@ fn upload_from_stdin( let pb = ProgressBar::new_spinner(); pb.set_style( - ProgressStyle::with_template("{spinner:.green} {bytes} uploaded ({elapsed})") - .unwrap(), + ProgressStyle::with_template("{spinner:.green} {bytes} uploaded ({elapsed})").unwrap(), ); pb.enable_steady_tick(std::time::Duration::from_millis(80)); let reader = pb.wrap_read(reader); @@ -239,7 +250,10 @@ fn create_dataset( println!("{}", "Dataset created".green()); println!("id: {}", dataset.id); println!("label: {}", dataset.label); - println!("full_name: datasets.{}.{}", dataset.schema_name, dataset.table_name); + println!( + "full_name: datasets.{}.{}", + dataset.schema_name, dataset.table_name + ); } pub fn create_from_upload( @@ -283,7 +297,9 @@ pub fn create_from_upload( }, }; - let (upload_id, format, upload_id_was_uploaded): (String, &str, bool) = if let Some(id) = upload_id { + let (upload_id, format, upload_id_was_uploaded): (String, &str, bool) = if let Some(id) = + upload_id + { (id.to_string(), source_format, false) } else { let (id, fmt) = match file { @@ -291,7 +307,9 @@ pub fn create_from_upload( None => { use std::io::IsTerminal; if std::io::stdin().is_terminal() { - eprintln!("error: no input data. Use --file , --upload-id , or pipe data via stdin."); + eprintln!( + "error: no input data. Use --file , --upload-id , or pipe data via stdin." + ); std::process::exit(1); } upload_from_stdin(&api) @@ -308,7 +326,10 @@ pub fn create_from_upload( use crossterm::style::Stylize; eprintln!( "{}", - format!("Resume dataset creation without re-uploading by passing --upload-id {uid}").yellow() + format!( + "Resume dataset creation without re-uploading by passing --upload-id {uid}" + ) + .yellow() ); })) } else { @@ -366,7 +387,13 @@ pub fn create_from_saved_query( } }; let api = ApiClient::new(Some(workspace_id)); - create_dataset(&api, label, table_name, json!({ "saved_query_id": query_id }), None); + create_dataset( + &api, + label, + table_name, + json!({ "saved_query_id": query_id }), + None, + ); } pub fn list(workspace_id: &str, limit: Option, offset: Option, format: &str) { @@ -386,18 +413,31 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: use crossterm::style::Stylize; eprintln!("{}", "No datasets found.".dark_grey()); } else { - let rows: Vec> = body.datasets.iter().map(|d| vec![ - d.id.clone(), - d.label.clone(), - format!("datasets.{}.{}", d.schema_name, d.table_name), - crate::util::format_date(&d.created_at), - ]).collect(); + let rows: Vec> = body + .datasets + .iter() + .map(|d| { + vec![ + d.id.clone(), + d.label.clone(), + format!("datasets.{}.{}", d.schema_name, d.table_name), + crate::util::format_date(&d.created_at), + ] + }) + .collect(); crate::table::print(&["ID", "LABEL", "FULL NAME", "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()); + eprintln!( + "{}", + format!( + "showing {} results โ€” use --offset {next} for more", + body.count + ) + .dark_grey() + ); } } _ => unreachable!(), @@ -423,9 +463,17 @@ pub fn get(dataset_id: &str, workspace_id: &str, format: &str) { println!("updated_at: {updated_at}"); if !d.columns.is_empty() { println!(); - let rows: Vec> = d.columns.iter().map(|col| vec![ - col.name.clone(), col.data_type.clone(), col.nullable.to_string(), - ]).collect(); + let rows: Vec> = d + .columns + .iter() + .map(|col| { + vec![ + col.name.clone(), + col.data_type.clone(), + col.nullable.to_string(), + ] + }) + .collect(); crate::table::print(&["COLUMN", "DATA TYPE", "NULLABLE"], &rows); } } diff --git a/src/embedding.rs b/src/embedding.rs index 11ae4d4..ec1c314 100644 --- a/src/embedding.rs +++ b/src/embedding.rs @@ -6,10 +6,12 @@ use serde_json::Value; pub fn read_vector_from_stdin() -> Vec { use std::io::Read; let mut input = String::new(); - std::io::stdin().read_to_string(&mut input).unwrap_or_else(|e| { - eprintln!("error reading stdin: {e}"); - std::process::exit(1); - }); + std::io::stdin() + .read_to_string(&mut input) + .unwrap_or_else(|e| { + eprintln!("error reading stdin: {e}"); + std::process::exit(1); + }); let input = input.trim(); if input.is_empty() { @@ -36,7 +38,8 @@ fn extract_vector(value: &Value) -> Vec { } // OpenAI response: {"data": [{"embedding": [...]}]} - if let Some(embedding) = value.get("data") + if let Some(embedding) = value + .get("data") .and_then(|d| d.get(0)) .and_then(|d| d.get("embedding")) .and_then(|e| e.as_array()) @@ -114,5 +117,11 @@ pub fn openai_embed(text: &str, model: &str) -> Vec { /// Format a vector as a SQL ARRAY literal: ARRAY[0.1,-0.2,...] pub fn vector_to_sql(vec: &[f64]) -> String { - format!("ARRAY[{}]", vec.iter().map(|v| v.to_string()).collect::>().join(",")) + format!( + "ARRAY[{}]", + vec.iter() + .map(|v| v.to_string()) + .collect::>() + .join(",") + ) } diff --git a/src/indexes.rs b/src/indexes.rs index 1606955..8629311 100644 --- a/src/indexes.rs +++ b/src/indexes.rs @@ -128,7 +128,12 @@ fn list_one_table(api: &ApiClient, connection_id: &str, schema: &str, table: &st body.indexes } -fn list_one_table_scan(api: &ApiClient, connection_id: &str, schema: &str, table: &str) -> Vec { +fn list_one_table_scan( + api: &ApiClient, + connection_id: &str, + schema: &str, + table: &str, +) -> Vec { let path = format!("/connections/{connection_id}/tables/{schema}/{table}/indexes"); match api.get_none_if_not_found::(&path) { Some(body) => body.indexes, @@ -210,13 +215,7 @@ pub fn list( .collect(); crate::table::print( &[ - "TABLE", - "NAME", - "TYPE", - "COLUMNS", - "METRIC", - "STATUS", - "CREATED", + "TABLE", "NAME", "TYPE", "COLUMNS", "METRIC", "STATUS", "CREATED", ], &table_rows, ); @@ -244,6 +243,7 @@ pub fn list( } } +#[allow(clippy::too_many_arguments)] pub fn create( workspace_id: &str, connection_id: &str, @@ -283,7 +283,10 @@ pub fn create( let job_id = parsed["job_id"].as_str().unwrap_or("unknown"); println!("{}", "Index creation submitted.".green()); println!("job_id: {}", job_id); - println!("{}", "Use 'hotdata jobs ' to check status.".dark_grey()); + println!( + "{}", + "Use 'hotdata jobs ' to check status.".dark_grey() + ); } else { println!("{}", "Index created.".green()); } diff --git a/src/jobs.rs b/src/jobs.rs index 8ef4105..c99d1f0 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -38,16 +38,35 @@ pub fn get(job_id: &str, workspace_id: &str, format: &str) { println!("{}{}", label("id:"), job.id); println!("{}{}", label("type:"), job.job_type); println!("{}{}", label("status:"), status_colored); - println!("{}{}", label("attempts:"), job.attempts.to_string().dark_cyan()); - println!("{}{}", label("created:"), crate::util::format_date(&job.created_at)); - println!("{}{}", label("completed:"), job.completed_at.as_deref().map(crate::util::format_date).unwrap_or_else(|| "-".dark_grey().to_string())); + println!( + "{}{}", + label("attempts:"), + job.attempts.to_string().dark_cyan() + ); + println!( + "{}{}", + label("created:"), + crate::util::format_date(&job.created_at) + ); + println!( + "{}{}", + label("completed:"), + job.completed_at + .as_deref() + .map(crate::util::format_date) + .unwrap_or_else(|| "-".dark_grey().to_string()) + ); if let Some(err) = &job.error_message { println!("{}{}", label("error:"), err.as_str().red()); } - if let Some(result) = &job.result { - if !result.is_null() { - println!("{}{}", label("result:"), serde_json::to_string_pretty(result).unwrap()); - } + if let Some(result) = &job.result + && !result.is_null() + { + println!( + "{}{}", + label("result:"), + serde_json::to_string_pretty(result).unwrap() + ); } } _ => unreachable!(), @@ -97,18 +116,34 @@ pub fn list( "table" => { if body.jobs.is_empty() { use crossterm::style::Stylize; - let msg = if !all && status.is_none() { "No active jobs found." } else { "No jobs found." }; + let msg = if !all && status.is_none() { + "No active jobs found." + } else { + "No jobs found." + }; eprintln!("{}", msg.dark_grey()); } else { - let rows: Vec> = body.jobs.iter().map(|j| vec![ - j.id.clone(), - j.job_type.clone(), - j.status.clone(), - j.attempts.to_string(), - crate::util::format_date(&j.created_at), - j.completed_at.as_deref().map(crate::util::format_date).unwrap_or_else(|| "-".to_string()), - ]).collect(); - crate::table::print(&["ID", "TYPE", "STATUS", "ATTEMPTS", "CREATED", "COMPLETED"], &rows); + let rows: Vec> = body + .jobs + .iter() + .map(|j| { + vec![ + j.id.clone(), + j.job_type.clone(), + j.status.clone(), + j.attempts.to_string(), + crate::util::format_date(&j.created_at), + j.completed_at + .as_deref() + .map(crate::util::format_date) + .unwrap_or_else(|| "-".to_string()), + ] + }) + .collect(); + crate::table::print( + &["ID", "TYPE", "STATUS", "ATTEMPTS", "CREATED", "COMPLETED"], + &rows, + ); } } _ => unreachable!(), diff --git a/src/main.rs b/src/main.rs index 4b47770..8fbda34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,11 @@ mod workspace; use anstyle::AnsiColor; use clap::{Parser, builder::Styles}; -use command::{AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, ContextCommands, DatasetsCommands, IndexesCommands, JobsCommands, QueriesCommands, QueryCommands, ResultsCommands, SandboxCommands, SkillCommands, TablesCommands, WorkspaceCommands}; +use command::{ + AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, ContextCommands, + DatasetsCommands, IndexesCommands, JobsCommands, QueriesCommands, QueryCommands, + ResultsCommands, SandboxCommands, 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)] @@ -46,11 +50,13 @@ struct Cli { fn resolve_workspace(provided: Option) -> String { // HOTDATA_WORKSPACE env var takes priority and blocks --workspace-id flag if let Ok(ws) = std::env::var("HOTDATA_WORKSPACE") { - if let Some(ref flag) = provided { - if flag != &ws { - eprintln!("error: cannot override workspace -- locked by HOTDATA_WORKSPACE environment variable ({ws})"); - std::process::exit(1); - } + if let Some(ref flag) = provided + && flag != &ws + { + eprintln!( + "error: cannot override workspace -- locked by HOTDATA_WORKSPACE environment variable ({ws})" + ); + std::process::exit(1); } return ws; } @@ -96,59 +102,112 @@ fn main() { Some(AuthCommands::Status) => auth::status("default"), Some(AuthCommands::Logout) => auth::logout("default"), }, - Commands::Datasets { id, workspace_id, output, command } => { + Commands::Datasets { + id, + workspace_id, + output, + command, + } => { let workspace_id = resolve_workspace(workspace_id); if let Some(id) = id { datasets::get(&id, &workspace_id, &output) } else { match command { - Some(DatasetsCommands::List { limit, offset, output }) => { - datasets::list(&workspace_id, limit, offset, &output) - } - Some(DatasetsCommands::Create { label, table_name, file, upload_id, format, sql, query_id, url }) => { + Some(DatasetsCommands::List { + limit, + offset, + output, + }) => datasets::list(&workspace_id, limit, offset, &output), + Some(DatasetsCommands::Create { + label, + table_name, + file, + upload_id, + format, + sql, + query_id, + url, + }) => { if let Some(sql) = sql { - datasets::create_from_query(&workspace_id, &sql, label.as_deref(), table_name.as_deref()) + datasets::create_from_query( + &workspace_id, + &sql, + label.as_deref(), + table_name.as_deref(), + ) } else if let Some(query_id) = query_id { - datasets::create_from_saved_query(&workspace_id, &query_id, label.as_deref(), table_name.as_deref()) + datasets::create_from_saved_query( + &workspace_id, + &query_id, + label.as_deref(), + table_name.as_deref(), + ) } else if let Some(url) = url { - datasets::create_from_url(&workspace_id, &url, label.as_deref(), table_name.as_deref()) + datasets::create_from_url( + &workspace_id, + &url, + label.as_deref(), + table_name.as_deref(), + ) } else { - datasets::create_from_upload(&workspace_id, label.as_deref(), table_name.as_deref(), file.as_deref(), upload_id.as_deref(), &format) + datasets::create_from_upload( + &workspace_id, + label.as_deref(), + table_name.as_deref(), + file.as_deref(), + upload_id.as_deref(), + &format, + ) } } None => { use clap::CommandFactory; let mut cmd = Cli::command(); cmd.build(); - cmd.find_subcommand_mut("datasets").unwrap().print_help().unwrap(); + cmd.find_subcommand_mut("datasets") + .unwrap() + .print_help() + .unwrap(); } } } } - Commands::Query { sql, workspace_id, connection, output, command } => { + Commands::Query { + sql, + workspace_id, + connection, + output, + command, + } => { let workspace_id = resolve_workspace(workspace_id); match command { - Some(QueryCommands::Status { id }) => { - query::poll(&id, &workspace_id, &output) - } - None => { - match sql { - Some(sql) => query::execute(&sql, &workspace_id, connection.as_deref(), &output), - None => { - use clap::CommandFactory; - let mut cmd = Cli::command(); - cmd.build(); - cmd.find_subcommand_mut("query").unwrap().print_help().unwrap(); - } + Some(QueryCommands::Status { id }) => query::poll(&id, &workspace_id, &output), + None => match sql { + Some(sql) => { + query::execute(&sql, &workspace_id, connection.as_deref(), &output) } - } + None => { + use clap::CommandFactory; + let mut cmd = Cli::command(); + cmd.build(); + cmd.find_subcommand_mut("query") + .unwrap() + .print_help() + .unwrap(); + } + }, } } Commands::Workspaces { command } => match command { WorkspaceCommands::List { output } => workspace::list(&output), WorkspaceCommands::Set { workspace_id } => workspace::set(workspace_id.as_deref()), }, - Commands::Connections { id, workspace_id, output, command } => { + Commands::Connections { + id, + workspace_id, + output, + command, + } => { let workspace_id = resolve_workspace(workspace_id); if let Some(id) = id { connections::get(&workspace_id, &id, &output) @@ -158,34 +217,46 @@ fn main() { Some(ConnectionsCommands::List { output }) => { connections::list(&workspace_id, &output) } - Some(ConnectionsCommands::Create { command, name, source_type, config, output }) => { - match command { - Some(ConnectionsCreateCommands::List { name, output }) => { - match name.as_deref() { - Some(name) => connections::types_get(&workspace_id, name, &output), - None => connections::types_list(&workspace_id, &output), + Some(ConnectionsCommands::Create { + command, + name, + source_type, + config, + output, + }) => match command { + Some(ConnectionsCreateCommands::List { name, output }) => { + match name.as_deref() { + Some(name) => { + connections::types_get(&workspace_id, name, &output) } + None => connections::types_list(&workspace_id, &output), } - None => { - let missing: Vec<&str> = [ - name.is_none().then_some("--name"), - source_type.is_none().then_some("--type"), - config.is_none().then_some("--config"), - ].into_iter().flatten().collect(); - if !missing.is_empty() { - eprintln!("error: missing required arguments: {}", missing.join(", ")); - std::process::exit(1); - } - connections::create( - &workspace_id, - &name.unwrap(), - &source_type.unwrap(), - &config.unwrap(), - &output, - ) + } + None => { + let missing: Vec<&str> = [ + name.is_none().then_some("--name"), + source_type.is_none().then_some("--type"), + config.is_none().then_some("--config"), + ] + .into_iter() + .flatten() + .collect(); + if !missing.is_empty() { + eprintln!( + "error: missing required arguments: {}", + missing.join(", ") + ); + std::process::exit(1); } + connections::create( + &workspace_id, + &name.unwrap(), + &source_type.unwrap(), + &config.unwrap(), + &output, + ) } - } + }, Some(ConnectionsCommands::Refresh { connection_id }) => { connections::refresh(&workspace_id, &connection_id) } @@ -193,78 +264,162 @@ fn main() { use clap::CommandFactory; let mut cmd = Cli::command(); cmd.build(); - cmd.find_subcommand_mut("connections").unwrap().print_help().unwrap(); + cmd.find_subcommand_mut("connections") + .unwrap() + .print_help() + .unwrap(); } } } - }, + } Commands::Tables { command } => match command { - TablesCommands::List { workspace_id, connection_id, schema, table, limit, cursor, output } => { + TablesCommands::List { + workspace_id, + connection_id, + schema, + table, + limit, + cursor, + output, + } => { let workspace_id = resolve_workspace(workspace_id); - tables::list(&workspace_id, connection_id.as_deref(), schema.as_deref(), table.as_deref(), limit, cursor.as_deref(), &output) + tables::list( + &workspace_id, + connection_id.as_deref(), + schema.as_deref(), + table.as_deref(), + limit, + cursor.as_deref(), + &output, + ) } }, Commands::Skills { command } => match command { SkillCommands::Install { project } => { - if project { skill::install_project() } else { skill::install() } + if project { + skill::install_project() + } else { + skill::install() + } } SkillCommands::Status => skill::status(), }, - Commands::Results { result_id, workspace_id, output, command } => { + Commands::Results { + result_id, + workspace_id, + output, + command, + } => { let workspace_id = resolve_workspace(workspace_id); match command { - Some(ResultsCommands::List { limit, offset, output }) => { - results::list(&workspace_id, limit, offset, &output) - } - None => { - match result_id { - Some(id) => results::get(&id, &workspace_id, &output), - None => { - use clap::CommandFactory; - let mut cmd = Cli::command(); - cmd.build(); - cmd.find_subcommand_mut("results").unwrap().print_help().unwrap(); - } + Some(ResultsCommands::List { + limit, + offset, + output, + }) => results::list(&workspace_id, limit, offset, &output), + None => match result_id { + Some(id) => results::get(&id, &workspace_id, &output), + None => { + use clap::CommandFactory; + let mut cmd = Cli::command(); + cmd.build(); + cmd.find_subcommand_mut("results") + .unwrap() + .print_help() + .unwrap(); } - } + }, } } - Commands::Jobs { id, workspace_id, output, command } => { + Commands::Jobs { + id, + workspace_id, + output, + command, + } => { let workspace_id = resolve_workspace(workspace_id); if let Some(id) = id { jobs::get(&id, &workspace_id, &output) } else { match command { - Some(JobsCommands::List { job_type, status, all, limit, offset, output }) => { - jobs::list(&workspace_id, job_type.as_deref(), status.as_deref(), all, limit, offset, &output) - } + Some(JobsCommands::List { + job_type, + status, + all, + limit, + offset, + output, + }) => jobs::list( + &workspace_id, + job_type.as_deref(), + status.as_deref(), + all, + limit, + offset, + &output, + ), None => { use clap::CommandFactory; let mut cmd = Cli::command(); cmd.build(); - cmd.find_subcommand_mut("jobs").unwrap().print_help().unwrap(); + cmd.find_subcommand_mut("jobs") + .unwrap() + .print_help() + .unwrap(); } } } } - Commands::Indexes { workspace_id, command } => { + Commands::Indexes { + workspace_id, + command, + } => { let workspace_id = resolve_workspace(workspace_id); match command { - IndexesCommands::List { connection_id, schema, table, output } => { - indexes::list( - &workspace_id, - connection_id.as_deref(), - schema.as_deref(), - table.as_deref(), - &output, - ) - } - IndexesCommands::Create { connection_id, schema, table, name, columns, r#type, metric, r#async } => { - indexes::create(&workspace_id, &connection_id, &schema, &table, &name, &columns, &r#type, metric.as_deref(), r#async) - } + IndexesCommands::List { + connection_id, + schema, + table, + output, + } => indexes::list( + &workspace_id, + connection_id.as_deref(), + schema.as_deref(), + table.as_deref(), + &output, + ), + IndexesCommands::Create { + connection_id, + schema, + table, + name, + columns, + r#type, + metric, + r#async, + } => indexes::create( + &workspace_id, + &connection_id, + &schema, + &table, + &name, + &columns, + &r#type, + metric.as_deref(), + r#async, + ), } } - Commands::Search { query, table, column, select, limit, model, workspace_id, output } => { + Commands::Search { + query, + table, + column, + select, + limit, + model, + workspace_id, + output, + } => { let workspace_id = resolve_workspace(workspace_id); let select_cols = select.as_deref().unwrap_or("*"); @@ -286,19 +441,7 @@ fn main() { "SELECT {}, l2_distance({}, {}) as dist FROM {} ORDER BY dist LIMIT {}", select_cols, column, vec_str, table, limit, ) - } else if query.is_none() { - use std::io::IsTerminal; - if std::io::stdin().is_terminal() { - eprintln!("error: provide a search query or pipe a vector via stdin"); - std::process::exit(1); - } - let vec = embedding::read_vector_from_stdin(); - let vec_str = embedding::vector_to_sql(&vec); - format!( - "SELECT {}, l2_distance({}, {}) as dist FROM {} ORDER BY dist LIMIT {}", - select_cols, column, vec_str, table, limit, - ) - } else { + } else if let Some(q) = query.as_ref() { let bm25_columns = match select.as_deref() { Some(cols) => format!("{}, score", cols), None => "*".to_string(), @@ -308,64 +451,108 @@ fn main() { bm25_columns, table.replace('\'', "''"), column.replace('\'', "''"), - query.unwrap().replace('\'', "''"), + q.replace('\'', "''"), limit, ) + } else { + use std::io::IsTerminal; + if std::io::stdin().is_terminal() { + eprintln!("error: provide a search query or pipe a vector via stdin"); + std::process::exit(1); + } + let vec = embedding::read_vector_from_stdin(); + let vec_str = embedding::vector_to_sql(&vec); + format!( + "SELECT {}, l2_distance({}, {}) as dist FROM {} ORDER BY dist LIMIT {}", + select_cols, column, vec_str, table, limit, + ) }; query::execute(&sql, &workspace_id, None, &output) } - Commands::Queries { id, output, command } => { + Commands::Queries { + id, + output, + command, + } => { let workspace_id = resolve_workspace(None); if let Some(id) = id { queries::get(&id, &workspace_id, &output) } else { match command { - Some(QueriesCommands::List { limit, cursor, status, output }) => { - queries::list(&workspace_id, Some(limit), cursor.as_deref(), status.as_deref(), &output) - } + Some(QueriesCommands::List { + limit, + cursor, + status, + output, + }) => queries::list( + &workspace_id, + Some(limit), + cursor.as_deref(), + status.as_deref(), + &output, + ), None => { use clap::CommandFactory; let mut cmd = Cli::command(); cmd.build(); - cmd.find_subcommand_mut("queries").unwrap().print_help().unwrap(); + cmd.find_subcommand_mut("queries") + .unwrap() + .print_help() + .unwrap(); } } } } - Commands::Sandbox { id, workspace_id, output, command } => { + Commands::Sandbox { + id, + workspace_id, + output, + command, + } => { let workspace_id = resolve_workspace(workspace_id); match command { Some(SandboxCommands::Run { name, cmd }) => { sandbox::run(id.as_deref(), &workspace_id, name.as_deref(), &cmd) } - Some(SandboxCommands::List { output }) => { - sandbox::list(&workspace_id, &output) - } + Some(SandboxCommands::List { output }) => sandbox::list(&workspace_id, &output), Some(SandboxCommands::New { name, output }) => { sandbox::new(&workspace_id, name.as_deref(), &output) } - Some(SandboxCommands::Update { id: update_id, name, markdown, output }) => { - let sandbox_id = update_id.or(id).or_else(|| { - config::load("default").ok().and_then(|p| p.sandbox) - }); + Some(SandboxCommands::Update { + id: update_id, + name, + markdown, + output, + }) => { + let sandbox_id = update_id + .or(id) + .or_else(|| config::load("default").ok().and_then(|p| p.sandbox)); match sandbox_id { - Some(sid) => sandbox::update(&workspace_id, &sid, name.as_deref(), markdown.as_deref(), &output), + Some(sid) => sandbox::update( + &workspace_id, + &sid, + name.as_deref(), + markdown.as_deref(), + &output, + ), None => { - eprintln!("error: no sandbox ID provided and no active sandbox set. Use 'sandbox new' or 'sandbox set '."); + eprintln!( + "error: no sandbox ID provided and no active sandbox set. Use 'sandbox new' or 'sandbox set '." + ); std::process::exit(1); } } } Some(SandboxCommands::Read) => { - let sandbox_id = id.or_else(|| { - std::env::var("HOTDATA_SANDBOX").ok() - }).or_else(|| { - config::load("default").ok().and_then(|p| p.sandbox) - }); + let sandbox_id = id + .or_else(|| std::env::var("HOTDATA_SANDBOX").ok()) + .or_else(|| config::load("default").ok().and_then(|p| p.sandbox)); match sandbox_id { Some(sid) => sandbox::read(&sid, &workspace_id), None => { - eprintln!("error: no active sandbox. Use 'sandbox new' or 'sandbox set '."); + eprintln!( + "error: no active sandbox. Use 'sandbox new' or 'sandbox set '." + ); std::process::exit(1); } } @@ -373,30 +560,38 @@ fn main() { Some(SandboxCommands::Set { id: set_id }) => { sandbox::set(set_id.as_deref(), &workspace_id) } - None => { - match id { - Some(id) => sandbox::get(&id, &workspace_id, &output), - None => { - use clap::CommandFactory; - let mut cmd = Cli::command(); - cmd.build(); - cmd.find_subcommand_mut("sandbox").unwrap().print_help().unwrap(); - } + None => match id { + Some(id) => sandbox::get(&id, &workspace_id, &output), + None => { + use clap::CommandFactory; + let mut cmd = Cli::command(); + cmd.build(); + cmd.find_subcommand_mut("sandbox") + .unwrap() + .print_help() + .unwrap(); } - } + }, } } - Commands::Context { workspace_id, command } => { + Commands::Context { + workspace_id, + command, + } => { let workspace_id = resolve_workspace(workspace_id); match command { ContextCommands::List { output, prefix } => { context::list(&workspace_id, prefix.as_deref(), &output) } ContextCommands::Show { name } => context::show(&workspace_id, &name), - ContextCommands::Pull { name, force, dry_run } => { - context::pull(&workspace_id, &name, force, dry_run) + ContextCommands::Pull { + name, + force, + dry_run, + } => context::pull(&workspace_id, &name, force, dry_run), + ContextCommands::Push { name, dry_run } => { + context::push(&workspace_id, &name, dry_run) } - ContextCommands::Push { name, dry_run } => context::push(&workspace_id, &name, dry_run), } } Commands::Completions { shell } => { diff --git a/src/queries.rs b/src/queries.rs index 63259b2..a007780 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -3,13 +3,12 @@ use crossterm::style::{Color, Stylize}; use serde::{Deserialize, Serialize}; const SQL_KEYWORDS: &[&str] = &[ - "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "IS", "NULL", "AS", - "ON", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "UNION", "ALL", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "CREATE", "DROP", - "ALTER", "TABLE", "INDEX", "VIEW", "WITH", "DISTINCT", "BETWEEN", "LIKE", - "CASE", "WHEN", "THEN", "ELSE", "END", "EXISTS", "ASC", "DESC", "TRUE", "FALSE", - "COUNT", "SUM", "AVG", "MIN", "MAX", "CAST", "COALESCE", "NULLIF", + "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "IS", "NULL", "AS", "ON", "JOIN", "LEFT", + "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "ORDER", "BY", "GROUP", "HAVING", "LIMIT", + "OFFSET", "UNION", "ALL", "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "CREATE", + "DROP", "ALTER", "TABLE", "INDEX", "VIEW", "WITH", "DISTINCT", "BETWEEN", "LIKE", "CASE", + "WHEN", "THEN", "ELSE", "END", "EXISTS", "ASC", "DESC", "TRUE", "FALSE", "COUNT", "SUM", "AVG", + "MIN", "MAX", "CAST", "COALESCE", "NULLIF", ]; fn highlight_sql(sql: &str) -> String { @@ -39,7 +38,9 @@ fn highlight_sql(sql: &str) -> String { while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '/') { i += 1; } - if i + 1 < len { i += 2; } + if i + 1 < len { + i += 2; + } let comment: String = chars[start..i].iter().collect(); result.push_str(&comment.dark_grey().to_string()); continue; @@ -50,7 +51,9 @@ fn highlight_sql(sql: &str) -> String { let start = i; i += 1; loop { - if i >= len { break; } + if i >= len { + break; + } if chars[i] == '\'' { i += 1; // '' is an escaped quote, continue the string @@ -173,25 +176,48 @@ pub fn list( let body: ListResponse = api.get_with_params("/query-runs", ¶ms); match format { - "json" => println!("{}", serde_json::to_string_pretty(&body.query_runs).unwrap()), + "json" => println!( + "{}", + serde_json::to_string_pretty(&body.query_runs).unwrap() + ), "yaml" => print!("{}", serde_yaml::to_string(&body.query_runs).unwrap()), "table" => { if body.query_runs.is_empty() { eprintln!("{}", "No query runs found.".dark_grey()); } else { - let rows: Vec> = body.query_runs.iter().map(|r| vec![ - r.id.clone(), - color_status(&r.status), - crate::util::format_date(&r.created_at), - r.execution_time_ms.map(|ms| ms.to_string()).unwrap_or_else(|| "-".to_string()), - r.row_count.map(|n| n.to_string()).unwrap_or_else(|| "-".to_string()), - truncate_sql(&r.sql_text, 60), - ]).collect(); - crate::table::print(&["ID", "STATUS", "CREATED", "DURATION_MS", "ROWS", "SQL"], &rows); + let rows: Vec> = body + .query_runs + .iter() + .map(|r| { + vec![ + r.id.clone(), + color_status(&r.status), + crate::util::format_date(&r.created_at), + r.execution_time_ms + .map(|ms| ms.to_string()) + .unwrap_or_else(|| "-".to_string()), + r.row_count + .map(|n| n.to_string()) + .unwrap_or_else(|| "-".to_string()), + truncate_sql(&r.sql_text, 60), + ] + }) + .collect(); + crate::table::print( + &["ID", "STATUS", "CREATED", "DURATION_MS", "ROWS", "SQL"], + &rows, + ); } if body.has_more { let next = body.next_cursor.as_deref().unwrap_or(""); - eprintln!("{}", format!("showing {} results โ€” use --cursor {next} for more", body.count).dark_grey()); + eprintln!( + "{}", + format!( + "showing {} results โ€” use --cursor {next} for more", + body.count + ) + .dark_grey() + ); } } _ => unreachable!(), @@ -213,7 +239,11 @@ fn print_detail(r: &QueryRun, format: &str) { let label = |l: &str| format!("{:<14}", l).dark_grey().to_string(); println!("{}{}", label("id:"), r.id); println!("{}{}", label("status:"), color_status(&r.status)); - println!("{}{}", label("created:"), crate::util::format_date(&r.created_at)); + println!( + "{}{}", + label("created:"), + crate::util::format_date(&r.created_at) + ); if let Some(ref c) = r.completed_at { println!("{}{}", label("completed:"), crate::util::format_date(c)); } @@ -230,7 +260,10 @@ fn print_detail(r: &QueryRun, format: &str) { println!("{}{}", label("result id:"), id); } if let Some(ref id) = r.saved_query_id { - let version = r.saved_query_version.map(|v| format!(" (v{v})")).unwrap_or_default(); + let version = r + .saved_query_version + .map(|v| format!(" (v{v})")) + .unwrap_or_default(); println!("{}{}{}", label("saved query:"), id, version); } println!("{}{}", label("snapshot:"), r.snapshot_id); diff --git a/src/query.rs b/src/query.rs index ccadea4..2e70ad7 100644 --- a/src/query.rs +++ b/src/query.rs @@ -71,9 +71,19 @@ pub fn execute(sql: &str, workspace_id: &str, connection: Option<&str>, format: } }; use crossterm::style::Stylize; - eprintln!("{}", format!("query still running (status: {})", async_resp.status).yellow()); + eprintln!( + "{}", + format!("query still running (status: {})", async_resp.status).yellow() + ); eprintln!("query_run_id: {}", async_resp.query_run_id); - eprintln!("{}", format!("Poll with: hotdata query status {}", async_resp.query_run_id).dark_grey()); + eprintln!( + "{}", + format!( + "Poll with: hotdata query status {}", + async_resp.query_run_id + ) + .dark_grey() + ); std::process::exit(2); } @@ -105,18 +115,16 @@ pub fn poll(query_run_id: &str, workspace_id: &str, format: &str) { let run: QueryRunResponse = api.get(&format!("/query-runs/{query_run_id}")); match run.status.as_str() { - "succeeded" => { - match run.result_id { - Some(ref result_id) => { - let result: QueryResponse = api.get(&format!("/results/{result_id}")); - print_result(&result, format); - } - None => { - use crossterm::style::Stylize; - println!("{}", "Query succeeded but no result available.".yellow()); - } + "succeeded" => match run.result_id { + Some(ref result_id) => { + let result: QueryResponse = api.get(&format!("/results/{result_id}")); + print_result(&result, format); } - } + None => { + use crossterm::style::Stylize; + println!("{}", "Query succeeded but no result available.".yellow()); + } + }, "failed" => { use crossterm::style::Stylize; let err = run.error.as_deref().unwrap_or("unknown error"); @@ -127,7 +135,10 @@ pub fn poll(query_run_id: &str, workspace_id: &str, format: &str) { use crossterm::style::Stylize; eprintln!("{}", format!("query status: {status}").yellow()); eprintln!("query_run_id: {}", run.id); - eprintln!("{}", format!("Poll again with: hotdata query status {}", run.id).dark_grey()); + eprintln!( + "{}", + format!("Poll again with: hotdata query status {}", run.id).dark_grey() + ); std::process::exit(2); } } @@ -152,22 +163,39 @@ pub fn print_result(result: &QueryResponse, format: &str) { "csv" => { println!("{}", result.columns.join(",")); for row in &result.rows { - let cells: Vec = row.iter().map(|v| { - let s = value_to_string(v); - if s.contains(',') || s.contains('"') || s.contains('\n') { - format!("\"{}\"", s.replace('"', "\"\"")) - } else { - s - } - }).collect(); + let cells: Vec = row + .iter() + .map(|v| { + let s = value_to_string(v); + if s.contains(',') || s.contains('"') || s.contains('\n') { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s + } + }) + .collect(); println!("{}", cells.join(",")); } } "table" => { crate::table::print_json(&result.columns, &result.rows); use crossterm::style::Stylize; - let id_part = result.result_id.as_deref().map(|id| format!(" [result-id: {id}]")).unwrap_or_default(); - eprintln!("{}", format!("\n{} row{} ({} ms){}", result.row_count, if result.row_count == 1 { "" } else { "s" }, result.execution_time_ms, id_part).dark_grey()); + let id_part = result + .result_id + .as_deref() + .map(|id| format!(" [result-id: {id}]")) + .unwrap_or_default(); + eprintln!( + "{}", + format!( + "\n{} row{} ({} ms){}", + result.row_count, + if result.row_count == 1 { "" } else { "s" }, + result.execution_time_ms, + id_part + ) + .dark_grey() + ); } _ => unreachable!(), } diff --git a/src/results.rs b/src/results.rs index d36deae..e76ce15 100644 --- a/src/results.rs +++ b/src/results.rs @@ -32,17 +32,30 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: 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(); + 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()); + eprintln!( + "{}", + format!( + "showing {} results โ€” use --offset {next} for more", + body.count + ) + .dark_grey() + ); } } _ => unreachable!(), diff --git a/src/sandbox.rs b/src/sandbox.rs index e25f144..468c989 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -37,15 +37,23 @@ pub fn list(workspace_id: &str, format: &str) { if body.sandboxes.is_empty() { eprintln!("{}", "No sandboxes found.".dark_grey()); } else { - let rows: Vec> = body.sandboxes.iter().map(|s| { - let marker = if current_sandbox.as_deref() == Some(&s.public_id) { "*" } else { "" }; - vec![ - marker.to_string(), - s.public_id.clone(), - s.name.clone(), - crate::util::format_date(&s.updated_at), - ] - }).collect(); + let rows: Vec> = body + .sandboxes + .iter() + .map(|s| { + let marker = if current_sandbox.as_deref() == Some(&s.public_id) { + "*" + } else { + "" + }; + vec![ + marker.to_string(), + s.public_id.clone(), + s.name.clone(), + crate::util::format_date(&s.updated_at), + ] + }) + .collect(); crate::table::print(&["ACTIVE", "ID", "NAME", "UPDATED"], &rows); } } @@ -66,8 +74,16 @@ pub fn get(sandbox_id: &str, workspace_id: &str, format: &str) { let label = |l: &str| format!("{:<12}", l).dark_grey().to_string(); println!("{}{}", label("id:"), s.public_id); println!("{}{}", label("name:"), s.name); - println!("{}{}", label("created:"), crate::util::format_date(&s.created_at)); - println!("{}{}", label("updated:"), crate::util::format_date(&s.updated_at)); + println!( + "{}{}", + label("created:"), + crate::util::format_date(&s.created_at) + ); + println!( + "{}{}", + label("updated:"), + crate::util::format_date(&s.updated_at) + ); if !s.markdown.is_empty() { println!(); println!("{}", "Markdown:".dark_grey()); @@ -105,9 +121,8 @@ fn find_sandbox_run_ancestor_inner() -> Option { use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind}; let sys = System::new_with_specifics( - RefreshKind::nothing().with_processes( - ProcessRefreshKind::nothing().with_cmd(UpdateKind::Always), - ), + RefreshKind::nothing() + .with_processes(ProcessRefreshKind::nothing().with_cmd(UpdateKind::Always)), ); let current_pid = sysinfo::get_current_pid().ok()?; @@ -116,12 +131,11 @@ fn find_sandbox_run_ancestor_inner() -> Option { for _ in 0..64 { let proc = sys.process(pid)?; let name = proc.name().to_string_lossy(); - if name == "hotdata" { - if proc.cmd().iter().any(|a| a == "sandbox") - && proc.cmd().iter().any(|a| a == "run") - { - return Some(pid); - } + if name == "hotdata" + && proc.cmd().iter().any(|a| a == "sandbox") + && proc.cmd().iter().any(|a| a == "run") + { + return Some(pid); } pid = proc.parent()?; } @@ -159,7 +173,13 @@ pub fn new(workspace_id: &str, name: Option<&str>, format: &str) { } } -pub fn update(workspace_id: &str, sandbox_id: &str, name: Option<&str>, markdown: Option<&str>, format: &str) { +pub fn update( + workspace_id: &str, + sandbox_id: &str, + name: Option<&str>, + markdown: Option<&str>, + format: &str, +) { if name.is_none() && markdown.is_none() { eprintln!("error: provide at least one of --name or --markdown."); std::process::exit(1); @@ -168,8 +188,12 @@ pub fn update(workspace_id: &str, sandbox_id: &str, name: Option<&str>, markdown let api = ApiClient::new(Some(workspace_id)); let mut body = serde_json::json!({}); - if let Some(n) = name { body["name"] = serde_json::json!(n); } - if let Some(m) = markdown { body["markdown"] = serde_json::json!(m); } + if let Some(n) = name { + body["name"] = serde_json::json!(n); + } + if let Some(m) = markdown { + body["markdown"] = serde_json::json!(m); + } let path = format!("/sandboxes/{sandbox_id}"); let resp: DetailResponse = api.patch(&path, &body); @@ -183,7 +207,11 @@ pub fn update(workspace_id: &str, sandbox_id: &str, name: Option<&str>, markdown let label = |l: &str| format!("{:<12}", l).dark_grey().to_string(); println!("{}{}", label("id:"), s.public_id); println!("{}{}", label("name:"), s.name); - println!("{}{}", label("updated:"), crate::util::format_date(&s.updated_at)); + println!( + "{}{}", + label("updated:"), + crate::util::format_date(&s.updated_at) + ); } _ => unreachable!(), } @@ -229,23 +257,6 @@ pub fn run(sandbox_id: Option<&str>, workspace_id: &str, name: Option<&str>, cmd } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn find_sandbox_run_ancestor_returns_none_in_test() { - // No `hotdata sandbox run` ancestor exists in the test runner - assert!(find_sandbox_run_ancestor_inner().is_none()); - } - - #[test] - fn find_sandbox_run_ancestor_cached_matches_inner() { - // The cached version should agree with the inner function - assert_eq!(find_sandbox_run_ancestor(), find_sandbox_run_ancestor_inner()); - } -} - pub fn set(sandbox_id: Option<&str>, workspace_id: &str) { check_sandbox_lock(); match sandbox_id { @@ -272,3 +283,23 @@ pub fn set(sandbox_id: Option<&str>, workspace_id: &str) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_sandbox_run_ancestor_returns_none_in_test() { + // No `hotdata sandbox run` ancestor exists in the test runner + assert!(find_sandbox_run_ancestor_inner().is_none()); + } + + #[test] + fn find_sandbox_run_ancestor_cached_matches_inner() { + // The cached version should agree with the inner function + assert_eq!( + find_sandbox_run_ancestor(), + find_sandbox_run_ancestor_inner() + ); + } +} diff --git a/src/skill.rs b/src/skill.rs index a3a6d87..3c01adc 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -165,15 +165,13 @@ fn ensure_symlink_or_copy(src: &PathBuf, link_path: &PathBuf) -> Result return Ok(true), - Err(_) => {} + if std::os::unix::fs::symlink(src, link_path).is_ok() { + return Ok(true); } #[cfg(windows)] - match std::os::windows::fs::symlink_dir(src, link_path) { - Ok(_) => return Ok(true), - Err(_) => {} + if std::os::windows::fs::symlink_dir(src, link_path).is_ok() { + return Ok(true); } copy_dir_recursive(src, link_path)?; @@ -255,7 +253,11 @@ pub fn install_project() { "{}", format!("Skill installed to project (v{current}).").green() ); - println!("{:<20}{}", "Location:", rel_agents.display().to_string().cyan()); + println!( + "{:<20}{}", + "Location:", + rel_agents.display().to_string().cyan() + ); // For .claude and .pi in cwd: symlink (fallback copy) from .agents/skills/hotdata for root in AGENT_ROOTS { @@ -264,8 +266,16 @@ pub fn install_project() { let link_path = root_path.join("skills").join(SKILL_NAME); let rel_link = link_path.strip_prefix(&cwd).unwrap_or(&link_path); match ensure_symlink_or_copy(&project_agents, &link_path) { - Ok(true) => println!("{:<20}{}", format!("./{root}:"), rel_link.display().to_string().cyan()), - Ok(false) => println!("{:<20}{} (copied)", format!("./{root}:"), rel_link.display().to_string().cyan()), + Ok(true) => println!( + "{:<20}{}", + format!("./{root}:"), + rel_link.display().to_string().cyan() + ), + Ok(false) => println!( + "{:<20}{} (copied)", + format!("./{root}:"), + rel_link.display().to_string().cyan() + ), Err(e) => eprintln!("{}", format!("./{root}: failed: {e}").red()), } } @@ -311,11 +321,9 @@ pub fn install() { } }; - if needs_download { - if let Err(e) = download_and_extract() { - eprintln!("{}", e.red()); - std::process::exit(1); - } + if needs_download && let Err(e) = download_and_extract() { + eprintln!("{}", e.red()); + std::process::exit(1); } let symlinks = ensure_symlinks(); @@ -394,7 +402,7 @@ pub fn status() { ); } - if installed_version.map_or(false, |v| v < current) { + if installed_version.is_some_and(|v| v < current) { println!("\nRun 'hotdata skills install' to update."); } } diff --git a/src/table.rs b/src/table.rs index f2935ad..c7618fd 100644 --- a/src/table.rs +++ b/src/table.rs @@ -10,10 +10,22 @@ use tabled::settings::{ pub fn truncate_array(arr: &[serde_json::Value]) -> (String, Option) { if arr.len() > 6 { let head: Vec = arr[..3].iter().map(|v| v.to_string()).collect(); - let tail: Vec = arr[arr.len()-3..].iter().map(|v| v.to_string()).collect(); - (format!("[{}, ..., {}]", head.join(", "), tail.join(", ")), Some(arr.len())) + let tail: Vec = arr[arr.len() - 3..].iter().map(|v| v.to_string()).collect(); + ( + format!("[{}, ..., {}]", head.join(", "), tail.join(", ")), + Some(arr.len()), + ) } else { - (format!("[{}]", arr.iter().map(|v| v.to_string()).collect::>().join(", ")), None) + ( + format!( + "[{}]", + arr.iter() + .map(|v| v.to_string()) + .collect::>() + .join(", ") + ), + None, + ) } } @@ -54,7 +66,12 @@ fn first_row_width(rows: &[Vec], col: usize) -> usize { .unwrap_or(0) } -fn style_table(table: &mut tabled::Table, num_cols: usize, id_col_indices: &[usize], id_widths: &[usize]) { +fn style_table( + table: &mut tabled::Table, + num_cols: usize, + id_col_indices: &[usize], + id_widths: &[usize], +) { let tw = term_width(); // Calculate how much space ID columns need (content + 3 for cell padding/borders) @@ -64,7 +81,11 @@ fn style_table(table: &mut tabled::Table, num_cols: usize, id_col_indices: &[usi let non_id_count = num_cols - id_col_indices.len(); let overhead = 1; // final border character let remaining = tw.saturating_sub(id_total + overhead); - let non_id_width = if non_id_count > 0 { remaining / non_id_count } else { 0 }; + let non_id_width = if non_id_count > 0 { + remaining / non_id_count + } else { + 0 + }; table.with(Style::modern_rounded()); @@ -73,7 +94,9 @@ fn style_table(table: &mut tabled::Table, num_cols: usize, id_col_indices: &[usi if id_col_indices.contains(&col) { continue; } - table.with(Modify::new(Columns::new(col..=col)).with(Width::wrap(non_id_width).keep_words(true))); + table.with( + Modify::new(Columns::new(col..=col)).with(Width::wrap(non_id_width).keep_words(true)), + ); } table @@ -116,23 +139,24 @@ pub fn print_json(headers: &[String], rows: &[Vec]) { let string_row: Vec = row .iter() .enumerate() - .map(|(ci, v)| { - match v { - serde_json::Value::Number(n) => { - colored_cells.push((ri + 1, ci, Color::FG_CYAN)); - n.to_string() - } - serde_json::Value::Null => { - colored_cells.push((ri + 1, ci, Color::FG_BRIGHT_BLACK)); - String::new() - } - serde_json::Value::Bool(b) => { - colored_cells.push((ri + 1, ci, Color::FG_YELLOW)); - b.to_string() - } - serde_json::Value::Array(arr) => format_array(arr), - _ => v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()), + .map(|(ci, v)| match v { + serde_json::Value::Number(n) => { + colored_cells.push((ri + 1, ci, Color::FG_CYAN)); + n.to_string() + } + serde_json::Value::Null => { + colored_cells.push((ri + 1, ci, Color::FG_BRIGHT_BLACK)); + String::new() + } + serde_json::Value::Bool(b) => { + colored_cells.push((ri + 1, ci, Color::FG_YELLOW)); + b.to_string() } + serde_json::Value::Array(arr) => format_array(arr), + _ => v + .as_str() + .map(str::to_string) + .unwrap_or_else(|| v.to_string()), }) .collect(); builder.push_record(&string_row); @@ -164,24 +188,34 @@ pub fn print_json(headers: &[String], rows: &[Vec]) { /// Distribute terminal width fairly across columns. /// Each column gets at least its natural width (header or content), up to /// an equal share. Surplus from narrow columns is redistributed to wider ones. -fn fair_column_widths(headers: &[String], rows: &[Vec], ncols: usize, tw: usize) -> Vec { - if ncols == 0 { return vec![]; } +fn fair_column_widths( + headers: &[String], + rows: &[Vec], + ncols: usize, + tw: usize, +) -> Vec { + if ncols == 0 { + return vec![]; + } // borders + padding: 1 left border + (3 per column: pad+border) => ncols*3 + 1 let overhead = ncols * 3 + 1; let available = tw.saturating_sub(overhead); // Natural width based on content, with header allowed to add up to 3 extra chars - let natural: Vec = (0..ncols).map(|i| { - let content_w = rows.iter() - .filter_map(|r| r.get(i)) - .map(|s| s.len()) - .max() - .unwrap_or(1); - let header_w = headers.get(i).map(|h| h.len()).unwrap_or(0); - let header_cap = content_w + 3; - content_w.max(header_w.min(header_cap)) - }).collect(); + let natural: Vec = (0..ncols) + .map(|i| { + let content_w = rows + .iter() + .filter_map(|r| r.get(i)) + .map(|s| s.len()) + .max() + .unwrap_or(1); + let header_w = headers.get(i).map(|h| h.len()).unwrap_or(0); + let header_cap = content_w + 3; + content_w.max(header_w.min(header_cap)) + }) + .collect(); // Iteratively distribute: cap at fair share, give surplus to remaining columns let mut widths = vec![0usize; ncols]; @@ -215,7 +249,9 @@ fn fair_column_widths(headers: &[String], rows: &[Vec], ncols: usize, tw // Ensure minimum width of 1 for w in &mut widths { - if *w == 0 { *w = 1; } + if *w == 0 { + *w = 1; + } } widths diff --git a/src/tables.rs b/src/tables.rs index 5ff302a..ab083d9 100644 --- a/src/tables.rs +++ b/src/tables.rs @@ -81,8 +81,13 @@ pub fn list( let next_cursor = body.next_cursor.clone(); if connection_id.is_some() { - let out: Vec = body.tables.into_iter() - .map(|t| TableWithColumns { table: t.full_name(), columns: t.columns }) + let out: Vec = body + .tables + .into_iter() + .map(|t| TableWithColumns { + table: t.full_name(), + columns: t.columns, + }) .collect(); match format { "json" => println!("{}", serde_json::to_string_pretty(&out).unwrap()), @@ -92,19 +97,33 @@ pub fn list( use crossterm::style::Stylize; eprintln!("{}", "No tables found.".dark_grey()); } else { - let rows: Vec> = out.iter().flat_map(|t| { - t.columns.iter().map(|col| vec![ - t.table.clone(), col.name.clone(), col.data_type.clone(), col.nullable.to_string(), - ]) - }).collect(); + let rows: Vec> = out + .iter() + .flat_map(|t| { + t.columns.iter().map(|col| { + vec![ + t.table.clone(), + col.name.clone(), + col.data_type.clone(), + col.nullable.to_string(), + ] + }) + }) + .collect(); crate::table::print(&["TABLE", "COLUMN", "DATA_TYPE", "NULLABLE"], &rows); } } _ => unreachable!(), } } else { - let mut out: Vec = body.tables.iter() - .map(|t| TableRow { table: t.full_name(), synced: t.synced, last_sync: t.last_sync.clone() }) + let mut out: Vec = body + .tables + .iter() + .map(|t| TableRow { + table: t.full_name(), + synced: t.synced, + last_sync: t.last_sync.clone(), + }) .collect(); out.sort_by(|a, b| a.table.cmp(&b.table)); match format { @@ -115,11 +134,19 @@ pub fn list( use crossterm::style::Stylize; eprintln!("{}", "No tables found.".dark_grey()); } else { - let rows: Vec> = out.iter().map(|r| vec![ - r.table.clone(), - r.synced.to_string(), - r.last_sync.as_deref().map(crate::util::format_date).unwrap_or_else(|| "-".to_string()), - ]).collect(); + let rows: Vec> = out + .iter() + .map(|r| { + vec![ + r.table.clone(), + r.synced.to_string(), + r.last_sync + .as_deref() + .map(crate::util::format_date) + .unwrap_or_else(|| "-".to_string()), + ] + }) + .collect(); crate::table::print(&["TABLE", "SYNCED", "LAST_SYNC"], &rows); } } @@ -129,6 +156,13 @@ pub fn list( if has_more { use crossterm::style::Stylize; - eprintln!("{}", format!("More results available. Use --cursor {} to fetch the next page.", next_cursor.as_deref().unwrap_or("")).dark_grey()); + eprintln!( + "{}", + format!( + "More results available. Use --cursor {} to fetch the next page.", + next_cursor.as_deref().unwrap_or("") + ) + .dark_grey() + ); } } diff --git a/src/util.rs b/src/util.rs index 8204f1f..d8bf3fb 100644 --- a/src/util.rs +++ b/src/util.rs @@ -5,9 +5,7 @@ use std::time::Duration; /// Writes to stderr so stdout (json/yaml output) stays clean. pub fn spinner(msg: &str) -> indicatif::ProgressBar { let pb = indicatif::ProgressBar::new_spinner(); - pb.set_style( - indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}").unwrap(), - ); + pb.set_style(indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}").unwrap()); pb.set_message(msg.to_string()); pb.enable_steady_tick(Duration::from_millis(80)); pb @@ -24,15 +22,25 @@ pub fn is_debug() -> bool { } /// Log request details when debug mode is enabled. -pub fn debug_request(method: &str, url: &str, headers: &[(&str, &str)], body: Option<&serde_json::Value>) { - if !is_debug() { return; } +pub fn debug_request( + method: &str, + url: &str, + headers: &[(&str, &str)], + body: Option<&serde_json::Value>, +) { + if !is_debug() { + return; + } use crossterm::style::Stylize; eprintln!("{}", format!(">>> {method} {url}").dark_cyan()); for (k, v) in headers { eprintln!("{}", format!(" {k}: {v}").dark_grey()); } if let Some(b) = body { - eprintln!("{}", colorize_json(&serde_json::to_string_pretty(b).unwrap())); + eprintln!( + "{}", + colorize_json(&serde_json::to_string_pretty(b).unwrap()) + ); } } @@ -44,14 +52,21 @@ pub fn debug_response(resp: reqwest::blocking::Response) -> (reqwest::StatusCode if is_debug() { use crossterm::style::Stylize; - let status_str = format!("<<< {} {}", status.as_u16(), status.canonical_reason().unwrap_or("")); + let status_str = format!( + "<<< {} {}", + status.as_u16(), + status.canonical_reason().unwrap_or("") + ); if status.is_success() { eprintln!("{}", status_str.dark_green()); } else { eprintln!("{}", status_str.dark_red()); } if let Ok(v) = serde_json::from_str::(&body) { - eprintln!("{}", colorize_json(&serde_json::to_string_pretty(&v).unwrap())); + eprintln!( + "{}", + colorize_json(&serde_json::to_string_pretty(&v).unwrap()) + ); } else if !body.is_empty() { eprintln!("{}", body.to_string().dark_grey()); } @@ -84,8 +99,10 @@ fn colorize_json(json: &str) -> String { // String value in array result.push_str(&line.yellow().to_string()); } - } else if trimmed.starts_with('{') || trimmed.starts_with('}') - || trimmed.starts_with('[') || trimmed.starts_with(']') + } else if trimmed.starts_with('{') + || trimmed.starts_with('}') + || trimmed.starts_with('[') + || trimmed.starts_with(']') { result.push_str(&line.dark_grey().to_string()); } else { @@ -107,11 +124,16 @@ fn colorize_json(json: &str) -> String { /// Find the colon separating a JSON key from its value, skipping the key string. fn find_key_colon(s: &str) -> Option { // Expect: "key": value - if !s.starts_with('"') { return None; } + if !s.starts_with('"') { + return None; + } let mut i = 1; let bytes = s.as_bytes(); while i < bytes.len() { - if bytes[i] == b'\\' { i += 2; continue; } + if bytes[i] == b'\\' { + i += 2; + continue; + } if bytes[i] == b'"' { // Found end of key, look for ": " if s.get(i + 1..i + 3) == Some(": ") { @@ -128,7 +150,11 @@ fn find_key_colon(s: &str) -> Option { fn colorize_json_value(v: &str) -> String { use crossterm::style::Stylize; let stripped = v.trim_end_matches(','); - let comma = if v.ends_with(',') { ",".dark_grey().to_string() } else { String::new() }; + let comma = if v.ends_with(',') { + ",".dark_grey().to_string() + } else { + String::new() + }; let colored = if stripped == "null" { stripped.dark_grey().to_string() diff --git a/src/workspace.rs b/src/workspace.rs index 6649706..3783475 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -17,7 +17,9 @@ struct ListResponse { } pub fn set(workspace_id: Option<&str>) { - if std::env::var("HOTDATA_WORKSPACE").is_ok() || crate::sandbox::find_sandbox_run_ancestor().is_some() { + if std::env::var("HOTDATA_WORKSPACE").is_ok() + || crate::sandbox::find_sandbox_run_ancestor().is_some() + { eprintln!("error: workspace is locked"); std::process::exit(1); } @@ -26,30 +28,36 @@ pub fn set(workspace_id: Option<&str>) { let workspaces = body.workspaces; let chosen = match workspace_id { - Some(id) => { - match workspaces.iter().find(|w| w.public_id == id) { - Some(w) => config::WorkspaceEntry { public_id: w.public_id.clone(), name: w.name.clone() }, - None => { - eprintln!("error: workspace '{id}' not found or you don't have access to it."); - std::process::exit(1); - } + Some(id) => match workspaces.iter().find(|w| w.public_id == id) { + Some(w) => config::WorkspaceEntry { + public_id: w.public_id.clone(), + name: w.name.clone(), + }, + None => { + eprintln!("error: workspace '{id}' not found or you don't have access to it."); + std::process::exit(1); } - } + }, None => { if workspaces.is_empty() { eprintln!("error: no workspaces available."); std::process::exit(1); } - let options: Vec = workspaces.iter() + let options: Vec = workspaces + .iter() .map(|w| format!("{} ({})", w.name, w.public_id)) .collect(); - let selection = match inquire::Select::new("Select default workspace:", options.clone()).prompt() { - Ok(s) => s, - Err(_) => std::process::exit(1), - }; + let selection = + match inquire::Select::new("Select default workspace:", options.clone()).prompt() { + Ok(s) => s, + Err(_) => std::process::exit(1), + }; let idx = options.iter().position(|o| o == &selection).unwrap(); let w = &workspaces[idx]; - config::WorkspaceEntry { public_id: w.public_id.clone(), name: w.name.clone() } + config::WorkspaceEntry { + public_id: w.public_id.clone(), + name: w.name.clone(), + } } }; @@ -72,15 +80,23 @@ pub fn list(format: &str) { std::process::exit(1); } }; - let default_id = std::env::var("HOTDATA_WORKSPACE") - .unwrap_or_else(|_| profile_config.workspaces.first().map(|w| w.public_id.clone()).unwrap_or_default()); + let default_id = std::env::var("HOTDATA_WORKSPACE").unwrap_or_else(|_| { + profile_config + .workspaces + .first() + .map(|w| w.public_id.clone()) + .unwrap_or_default() + }); let api = ApiClient::new(None); let body: ListResponse = api.get("/workspaces"); match format { "json" => { - println!("{}", serde_json::to_string_pretty(&body.workspaces).unwrap()); + println!( + "{}", + serde_json::to_string_pretty(&body.workspaces).unwrap() + ); } "yaml" => { print!("{}", serde_yaml::to_string(&body.workspaces).unwrap()); @@ -90,10 +106,19 @@ pub fn list(format: &str) { use crossterm::style::Stylize; eprintln!("{}", "No workspaces found.".dark_grey()); } else { - let rows: Vec> = body.workspaces.iter().map(|w| { - let marker = if w.public_id == default_id { "*" } else { "" }; - vec![marker.to_string(), w.public_id.clone(), w.name.clone(), w.provision_status.clone()] - }).collect(); + let rows: Vec> = body + .workspaces + .iter() + .map(|w| { + let marker = if w.public_id == default_id { "*" } else { "" }; + vec![ + marker.to_string(), + w.public_id.clone(), + w.name.clone(), + w.provision_status.clone(), + ] + }) + .collect(); crate::table::print(&["DEFAULT", "PUBLIC_ID", "NAME", "PROVISION_STATUS"], &rows); } } From 6d17d47808abb29ecaef936dc0ac2c51604b94ba Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:52:43 -0700 Subject: [PATCH 2/3] ci(codecov): treat patch coverage as informational Patch targets only lines changed in a PR; release and formatting diffs often lower that percentage without indicating a regression. Keep upload and project trends; stop patch status from failing checks. --- codecov.yml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..cf193b7 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,9 @@ +# https://docs.codecov.com/docs/codecovyaml +coverage: + status: + patch: + default: + # Patch % only measures lines touched in the PR. Release/version + # bumps, rustfmt, and refactors often edit large surface areas + # without new tests; do not fail CI on patch coverage alone. + informational: true From 5439119ea7e9f69c5e2079552e0a51911f9c9da3 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:29:22 -0700 Subject: [PATCH 3/3] docs(changelog): regenerate for 0.1.14 and note JWT auth git-cliff updated the release section (date, codecov chore). The main auth commit used a non-conventional subject (missing colon), so cliff skipped it; document JWT session support explicitly. --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 874d8f9..5f2f759 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,14 @@ -## [0.1.14] - 2026-04-27 +## [0.1.14] - 2026-04-28 ### ๐Ÿš€ Features +- *(auth)* Add CLI auth session support (JWT access tokens, refresh, PKCE login) - *(indexes)* Workspace-wide list with filters and parallel fetch +### ๐Ÿ’ผ Other + +- *(codecov)* Treat patch coverage as informational + ### ๐Ÿงช Testing - Raise coverage for indexes list and get_none_if_not_found