From 0a0999e1c6b72b220bd7fcbb09958068e5c62ac0 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Tue, 28 Apr 2026 15:23:25 -0700 Subject: [PATCH 1/6] feat(datasets): add update subcommand to rename label or table_name --- src/api.rs | 59 +++++++++++++++++++++++++++++++++++-------------- src/command.rs | 18 +++++++++++++++ src/datasets.rs | 39 ++++++++++++++++++++++++++++++++ src/main.rs | 12 ++++++++++ 4 files changed, 112 insertions(+), 16 deletions(-) diff --git a/src/api.rs b/src/api.rs index 4e27bf7..9e0e81c 100644 --- a/src/api.rs +++ b/src/api.rs @@ -39,7 +39,10 @@ impl ApiClient { Ok(t) => t, Err(e) => { eprintln!("{}", format!("error: {e}").red()); - eprintln!("Run {} to log in, or pass --api-key.", "hotdata auth".cyan()); + eprintln!( + "Run {} to log in, or pass --api-key.", + "hotdata auth".cyan() + ); std::process::exit(1); } }; @@ -71,22 +74,32 @@ impl ApiClient { } } - /// Prints an error for a non-2xx response and exits. On 4xx, first re-probes /// the API key: if it's actually invalid, a clear re-auth hint is shown /// 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); @@ -128,12 +141,19 @@ 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); - let req = self.build_request(reqwest::Method::GET, &url).query(&filtered); + let req = self + .build_request(reqwest::Method::GET, &url) + .query(&filtered); let (status, body) = self.send(req, None); if !status.is_success() { self.fail_response(status, body); @@ -205,6 +225,17 @@ impl ApiClient { Self::parse_json(&resp_body) } + /// PUT request with JSON body, returns parsed response. + pub fn put(&self, path: &str, body: &serde_json::Value) -> T { + let url = format!("{}{path}", self.api_url); + let req = self.build_request(reqwest::Method::PUT, &url).json(body); + let (status, resp_body) = self.send(req, Some(body)); + if !status.is_success() { + self.fail_response(status, resp_body); + } + Self::parse_json(&resp_body) + } + /// POST with a custom request body (for file uploads). Returns raw status and body. pub fn post_body( &self, @@ -214,7 +245,8 @@ impl ApiClient { content_length: Option, ) -> (reqwest::StatusCode, String) { let url = format!("{}{path}", self.api_url); - 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 { req = req.header("Content-Length", len); @@ -225,7 +257,6 @@ impl ApiClient { // Authorization) still log. self.send(req, None) } - } /// Decide what error text to print for a failed response. Pulled out as a pure @@ -365,11 +396,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/command.rs b/src/command.rs index d284ec7..fbefeaf 100644 --- a/src/command.rs +++ b/src/command.rs @@ -384,6 +384,24 @@ pub enum DatasetsCommands { #[arg(long, conflicts_with_all = ["file", "upload_id", "sql", "query_id"])] url: Option, }, + + /// Update a dataset's label and/or table name + Update { + /// Dataset ID + id: String, + + /// New display label + #[arg(long)] + label: Option, + + /// New SQL table name (must be a valid identifier) + #[arg(long)] + table_name: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, } #[derive(Subcommand)] diff --git a/src/datasets.rs b/src/datasets.rs index 3d51778..615f3a4 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -480,3 +480,42 @@ pub fn get(dataset_id: &str, workspace_id: &str, format: &str) { _ => unreachable!(), } } + +pub fn update( + dataset_id: &str, + workspace_id: &str, + label: Option<&str>, + table_name: Option<&str>, + format: &str, +) { + if label.is_none() && table_name.is_none() { + eprintln!("error: provide at least one of --label or --table-name."); + std::process::exit(1); + } + + let api = ApiClient::new(Some(workspace_id)); + + let mut body = json!({}); + if let Some(l) = label { + body["label"] = json!(l); + } + if let Some(tn) = table_name { + body["table_name"] = json!(tn); + } + + let d: DatasetDetail = api.put(&format!("/datasets/{dataset_id}"), &body); + + use crossterm::style::Stylize; + eprintln!("{}", "Dataset updated".green()); + match format { + "json" => println!("{}", serde_json::to_string_pretty(&d).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&d).unwrap()), + "table" => { + println!("id: {}", d.id); + println!("label: {}", d.label); + println!("full_name: datasets.{}.{}", d.schema_name, d.table_name); + println!("updated_at: {}", crate::util::format_date(&d.updated_at)); + } + _ => unreachable!(), + } +} diff --git a/src/main.rs b/src/main.rs index 9a7da72..d213fdc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -161,6 +161,18 @@ fn main() { ) } } + Some(DatasetsCommands::Update { + id, + label, + table_name, + output, + }) => datasets::update( + &id, + &workspace_id, + label.as_deref(), + table_name.as_deref(), + &output, + ), None => { use clap::CommandFactory; let mut cmd = Cli::command(); From 5abb7a84987556afb90470223a45e79ee904ff05 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Tue, 28 Apr 2026 15:34:23 -0700 Subject: [PATCH 2/6] fix(datasets): match runtimedb response shape on update --- README.md | 3 ++- src/datasets.rs | 70 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d6c86e1..ae5ac99 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ API key priority (lowest to highest): config file → `HOTDATA_API_KEY` env var | `workspaces` | `list`, `set` | Manage workspaces | | `connections` | `list`, `create`, `refresh`, `new` | Manage connections | | `tables` | `list` | List tables and columns | -| `datasets` | `list`, `create` | Manage uploaded datasets | +| `datasets` | `list`, `create`, `update` | Manage uploaded datasets | | `context` | `list`, `show`, `pull`, `push` | Workspace Markdown context (e.g. data model `DATAMODEL`) via the context API | | `query` | | Execute a SQL query | | `queries` | `list` | Inspect query run history | @@ -142,6 +142,7 @@ hotdata datasets [--workspace-id ] [--format table|json|yaml] hotdata datasets create --file data.csv [--label "My Dataset"] [--table-name my_dataset] hotdata datasets create --sql "SELECT ..." --label "My Dataset" hotdata datasets create --url "https://example.com/data.parquet" --label "My Dataset" +hotdata datasets update [--label "New Label"] [--table-name new_table] ``` - Datasets are queryable as `datasets.main.`. diff --git a/src/datasets.rs b/src/datasets.rs index 615f3a4..e7a8ac9 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -54,6 +54,20 @@ struct DatasetDetail { columns: Vec, } +#[derive(Deserialize, Serialize)] +struct UpdateResponse { + id: String, + label: String, + #[serde(default = "default_schema")] + schema_name: String, + table_name: String, + #[serde(default)] + latest_version: Option, + #[serde(default)] + pinned_version: Option, + updated_at: String, +} + struct FileType { content_type: &'static str, format: &'static str, @@ -503,7 +517,7 @@ pub fn update( body["table_name"] = json!(tn); } - let d: DatasetDetail = api.put(&format!("/datasets/{dataset_id}"), &body); + let d: UpdateResponse = api.put(&format!("/datasets/{dataset_id}"), &body); use crossterm::style::Stylize; eprintln!("{}", "Dataset updated".green()); @@ -519,3 +533,57 @@ pub fn update( _ => unreachable!(), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Mirrors runtimedb's `UpdateDatasetResponse` (see runtimedb/src/http/models.rs). + /// The CLI must deserialize this exact shape — schema_name, source_type, + /// created_at, and columns are NOT in the response. If runtimedb's response + /// gains or loses fields, update this fixture in lockstep. + #[test] + fn update_response_deserializes_runtimedb_payload() { + let body = serde_json::json!({ + "id": "ds_abc123", + "label": "url_test", + "table_name": "url_test", + "latest_version": 3, + "updated_at": "2026-04-28T18:30:00Z", + }); + let resp: UpdateResponse = serde_json::from_value(body).unwrap(); + assert_eq!(resp.id, "ds_abc123"); + assert_eq!(resp.label, "url_test"); + assert_eq!(resp.table_name, "url_test"); + assert_eq!(resp.schema_name, "main"); // defaulted + assert_eq!(resp.latest_version, Some(3)); + assert!(resp.pinned_version.is_none()); + } + + #[test] + fn update_response_handles_pinned_version() { + let body = serde_json::json!({ + "id": "ds_abc123", + "label": "x", + "table_name": "x", + "latest_version": 5, + "pinned_version": 2, + "updated_at": "2026-04-28T18:30:00Z", + }); + let resp: UpdateResponse = serde_json::from_value(body).unwrap(); + assert_eq!(resp.pinned_version, Some(2)); + } + + #[test] + fn update_response_tolerates_missing_latest_version() { + // Defensive: treat latest_version as optional in case the server omits it. + let body = serde_json::json!({ + "id": "ds_abc123", + "label": "x", + "table_name": "x", + "updated_at": "2026-04-28T18:30:00Z", + }); + let resp: UpdateResponse = serde_json::from_value(body).unwrap(); + assert!(resp.latest_version.is_none()); + } +} From 36c794088b8376bcd13d36b822d5d096f14e422d Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Tue, 28 Apr 2026 16:01:26 -0700 Subject: [PATCH 3/6] fix(datasets): drop synthetic schema_name on update output --- src/datasets.rs | 45 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/datasets.rs b/src/datasets.rs index e7a8ac9..6662692 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -58,8 +58,12 @@ struct DatasetDetail { struct UpdateResponse { id: String, label: String, - #[serde(default = "default_schema")] - schema_name: String, + // Not currently in runtimedb's UpdateDatasetResponse; kept Optional so we + // print `full_name` only when the server actually returns the schema. + // Synthesizing "main" is wrong for sandbox-scoped datasets where + // schema_name == sandbox_id. + #[serde(default)] + schema_name: Option, table_name: String, #[serde(default)] latest_version: Option, @@ -527,7 +531,23 @@ pub fn update( "table" => { println!("id: {}", d.id); println!("label: {}", d.label); - println!("full_name: datasets.{}.{}", d.schema_name, d.table_name); + match &d.schema_name { + Some(schema) => { + println!("full_name: datasets.{}.{}", schema, d.table_name); + } + None => { + println!("table_name: {}", d.table_name); + use crossterm::style::Stylize; + eprintln!( + "{}", + format!( + "(run `hotdata datasets {}` to see the qualified name)", + d.id + ) + .dark_grey() + ); + } + } println!("updated_at: {}", crate::util::format_date(&d.updated_at)); } _ => unreachable!(), @@ -555,11 +575,28 @@ mod tests { assert_eq!(resp.id, "ds_abc123"); assert_eq!(resp.label, "url_test"); assert_eq!(resp.table_name, "url_test"); - assert_eq!(resp.schema_name, "main"); // defaulted + // The server doesn't currently send schema_name, so we don't synthesize + // one — sandbox-scoped datasets live under datasets.., + // not datasets.main.*, and a fabricated "main" would mislead users. + assert!(resp.schema_name.is_none()); assert_eq!(resp.latest_version, Some(3)); assert!(resp.pinned_version.is_none()); } + #[test] + fn update_response_uses_schema_name_when_server_supplies_it() { + // Forward-compat: if runtimedb later includes schema_name, we use it. + let body = serde_json::json!({ + "id": "ds_abc123", + "label": "x", + "schema_name": "sandbox_xyz", + "table_name": "x", + "updated_at": "2026-04-28T18:30:00Z", + }); + let resp: UpdateResponse = serde_json::from_value(body).unwrap(); + assert_eq!(resp.schema_name.as_deref(), Some("sandbox_xyz")); + } + #[test] fn update_response_handles_pinned_version() { let body = serde_json::json!({ From bd861d4a18297b7db414a7885b257a59703c252a Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Tue, 28 Apr 2026 17:52:17 -0700 Subject: [PATCH 4/6] style(datasets): drop redundant Stylize import in update path --- src/datasets.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/datasets.rs b/src/datasets.rs index 6662692..0dff510 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -537,7 +537,6 @@ pub fn update( } None => { println!("table_name: {}", d.table_name); - use crossterm::style::Stylize; eprintln!( "{}", format!( From fb8af95a8e3313d2d5ad504f9b87227c225b5670 Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Tue, 28 Apr 2026 17:58:39 -0700 Subject: [PATCH 5/6] Update src/datasets.rs Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- src/datasets.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/datasets.rs b/src/datasets.rs index 0dff510..7b354a8 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -524,7 +524,7 @@ pub fn update( let d: UpdateResponse = api.put(&format!("/datasets/{dataset_id}"), &body); use crossterm::style::Stylize; - eprintln!("{}", "Dataset updated".green()); + println!("{}", "Dataset updated".green()); match format { "json" => println!("{}", serde_json::to_string_pretty(&d).unwrap()), "yaml" => print!("{}", serde_yaml::to_string(&d).unwrap()), From 10096ccf3fe924dde390ff9ae0e443f8dad8705b Mon Sep 17 00:00:00 2001 From: Zac Farrell Date: Tue, 28 Apr 2026 18:05:54 -0700 Subject: [PATCH 6/6] fix(datasets): restore eprintln for "Dataset updated" status line Reverts fb8af95. The suggestion changed eprintln! to println!, which puts the human prelude on stdout and breaks `-o json` / `-o yaml` piping (e.g. `... -o json | jq` fails with a parse error because the status line precedes the JSON). --- src/datasets.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/datasets.rs b/src/datasets.rs index 7b354a8..0dff510 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -524,7 +524,7 @@ pub fn update( let d: UpdateResponse = api.put(&format!("/datasets/{dataset_id}"), &body); use crossterm::style::Stylize; - println!("{}", "Dataset updated".green()); + eprintln!("{}", "Dataset updated".green()); match format { "json" => println!("{}", serde_json::to_string_pretty(&d).unwrap()), "yaml" => print!("{}", serde_yaml::to_string(&d).unwrap()),