diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55efe72..5f2f759 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,17 @@
+## [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
## [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.
-
+
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
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 75a1599..4e27bf7 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -236,10 +236,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())
}
diff --git a/src/auth.rs b/src/auth.rs
index 72d0383..928d732 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -361,8 +361,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| {
@@ -372,6 +372,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::*;
@@ -699,15 +718,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 421d275..43bdf5b 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(pub(crate) 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;
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 1149f90..29b362e 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())
@@ -110,5 +113,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/jwt.rs b/src/jwt.rs
index d6c46c3..2f30930 100644
--- a/src/jwt.rs
+++ b/src/jwt.rs
@@ -276,11 +276,10 @@ pub fn ensure_access_token(
if matches!(
profile.api_key_source,
config::ApiKeySource::Flag | config::ApiKeySource::Env
- ) {
- if let Some(api_key) = api_key_fallback {
- let session = mint_from_api_token(profile, api_key)?;
- return Ok(session.access_token);
- }
+ ) && let Some(api_key) = api_key_fallback
+ {
+ let session = mint_from_api_token(profile, api_key)?;
+ return Ok(session.access_token);
}
let now = now_unix();
@@ -443,7 +442,7 @@ mod tests {
assert!(session.access_expires_at > now_unix());
// PKCE-origin sessions get the 7-day refresh TTL hint.
let ttl = session.refresh_expires_at.saturating_sub(now_unix());
- assert!(ttl >= 7 * 24 * 60 * 60 - 5 && ttl <= 7 * 24 * 60 * 60 + 5);
+ assert!((7 * 24 * 60 * 60 - 5..=7 * 24 * 60 * 60 + 5).contains(&ttl));
}
#[test]
@@ -527,7 +526,7 @@ mod tests {
assert_eq!(session.source, "api_token");
// api_token-origin sessions get the shorter 36h refresh TTL hint.
let ttl = session.refresh_expires_at.saturating_sub(now_unix());
- assert!(ttl >= 36 * 60 * 60 - 5 && ttl <= 36 * 60 * 60 + 5);
+ assert!((36 * 60 * 60 - 5..=36 * 60 * 60 + 5).contains(&ttl));
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index 6b04cd1..9a7da72 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -22,7 +22,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)]
@@ -47,11 +51,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;
}
@@ -97,59 +103,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)
@@ -159,34 +218,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)
}
@@ -194,78 +265,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("*");
@@ -287,19 +442,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(),
@@ -309,64 +452,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);
}
}
@@ -374,30 +561,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 7b65a0f..c26d19f 100644
--- a/src/skill.rs
+++ b/src/skill.rs
@@ -169,15 +169,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)?;
@@ -259,7 +257,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 {
@@ -268,8 +270,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()),
}
}
@@ -315,11 +325,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();
@@ -398,7 +406,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 7a74f07..99dce67 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -277,6 +277,19 @@ pub fn format_date(s: &str) -> String {
s.chars().take(16).collect()
}
+pub fn api_error(body: String) -> String {
+ serde_json::from_str::(&body)
+ .ok()
+ .and_then(|v| v["error"]["message"].as_str().map(str::to_string))
+ .unwrap_or_else(|| {
+ if body.trim_start().starts_with('<') {
+ "unexpected server error".to_string()
+ } else {
+ body
+ }
+ })
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -340,16 +353,3 @@ mod tests {
assert_eq!(v["refresh_token"], 123);
}
}
-
-pub fn api_error(body: String) -> String {
- serde_json::from_str::(&body)
- .ok()
- .and_then(|v| v["error"]["message"].as_str().map(str::to_string))
- .unwrap_or_else(|| {
- if body.trim_start().starts_with('<') {
- "unexpected server error".to_string()
- } else {
- body
- }
- })
-}
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);
}
}