diff --git a/src/cli.rs b/src/cli.rs index f84ae6f..6545f61 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -114,7 +114,7 @@ pub enum Commands { #[arg(long, visible_alias = "in")] index: Option, - /// Database the index lives in (id; defaults to the active database) + /// Database the index lives in (id, catalog, or name; defaults to the active database) #[arg(long, short = 'd')] database: Option, diff --git a/src/commands/databases.rs b/src/commands/databases.rs index 4daa1c0..f284344 100644 --- a/src/commands/databases.rs +++ b/src/commands/databases.rs @@ -407,6 +407,11 @@ pub struct Database { pub name: Option, #[serde(default)] pub default_catalog: Option, + /// Id of the database's own catalog. runtimedb removed connections as a + /// concept (the engine is push-only; every connection row IS a catalog), + /// but the wire field is still named `default_connection_id` — the CLI's + /// own output already speaks catalog. + #[serde(rename = "default_catalog_id")] pub default_connection_id: String, #[serde(default)] pub expires_at: Option, @@ -454,6 +459,9 @@ impl From for ForkedFrom { #[derive(Clone, Debug, Serialize, PartialEq, Eq)] struct DatabaseAttachment { + /// Catalog id under its post-connections-removal name (see + /// [`Database::default_connection_id`]); also the `detach` handle. + #[serde(rename = "catalog_id")] connection_id: String, alias: Option, } @@ -482,6 +490,9 @@ struct CreateDatabaseResponse { name: Option, #[serde(default)] default_catalog: Option, + /// Parses the server's `default_connection_id`, but `-o json`/`-o yaml` + /// emit it as `default_catalog_id` (see [`Database::default_connection_id`]). + #[serde(rename(serialize = "default_catalog_id"))] default_connection_id: String, #[serde(default)] expires_at: Option, @@ -671,6 +682,60 @@ pub fn resolve_database(api: &Api, id_or_name: &str) -> Database { } } +/// Like [`try_resolve_database`], but prefer the API's ambient database scope +/// (`HOTDATA_DATABASE` / current database) when its catalog or name matches +/// `key`. This disambiguates the common case where several databases share a +/// catalog (e.g. `default` across create/fork): the one the user is working in +/// wins instead of the lookup erroring as ambiguous. A key that names neither +/// the active database's catalog nor its name resolves like any other lookup. +pub fn try_resolve_database_preferring_active(api: &Api, key: &str) -> Result { + if let Some(active_id) = api.database_id().filter(|id| *id != key) + && let Some(active) = none_if_404(get_database(api, active_id)).unwrap_or_else(|e| e.exit()) + && (active.default_catalog.as_deref() == Some(key) || active.name.as_deref() == Some(key)) + { + return Ok(active); + } + try_resolve_database(api, key) +} + +/// Exiting wrapper around [`try_resolve_database_preferring_active`], mirroring +/// [`resolve_database`]. +pub fn resolve_database_preferring_active(api: &Api, key: &str) -> Database { + match try_resolve_database_preferring_active(api, key) { + Ok(db) => db, + Err(e) => { + use crossterm::style::Stylize; + eprintln!("{}", format!("error: {e}").red()); + std::process::exit(1); + } + } +} + +/// Every database id carries this prefix; anything else in a `--database` flag +/// is a catalog or name that needs resolving before it can scope a request. +const DATABASE_ID_PREFIX: &str = "dbid"; + +/// True when a `--database` flag value is already an id (no resolve needed). +fn is_database_id(value: &str) -> bool { + value.starts_with(DATABASE_ID_PREFIX) +} + +/// Resolve a `--database` flag value to the id request scoping needs (the +/// `X-Database-Id` header or a database-scoped path segment). An id passes +/// through untouched — the common path pays no round trip — while a catalog +/// or name resolves like every other database lookup (preferring the active +/// database on an ambiguous catalog), so `-d ` works everywhere +/// `-d ` does instead of the server rejecting the raw name. Takes the +/// caller's `Api` (callers all have one, or are about to build one to scope) +/// rather than constructing a second. Exits with the resolver's message when +/// nothing matches. +pub fn resolve_database_flag(api: &Api, key: &str) -> String { + if is_database_id(key) { + return key.to_string(); + } + resolve_database_preferring_active(api, key).id +} + fn schema_name(schema: Option<&str>) -> &str { schema.unwrap_or(DEFAULT_SCHEMA) } @@ -2084,15 +2149,31 @@ fn database_exists_or_unverifiable(result: Result) -> Result } } -pub fn set(workspace_id: &str, id: &str) { +pub fn set(workspace_id: &str, id_or_name: &str) { use crossterm::style::Stylize; // `set` only writes local config; the GET is just a friendly existence-check. let api = Api::new(Some(workspace_id)); - if !database_exists_or_unverifiable(get_database(&api, id)).unwrap_or_else(|e| e.exit()) { - eprintln!("{}", format!("error: no database with id '{id}'").red()); - std::process::exit(1); - } - if let Err(e) = crate::config::save_current_database("default", workspace_id, id) { + let id = if is_database_id(id_or_name) { + // Ids keep the 403-tolerant existence check: a database API token is + // denied `GET /v1/databases/{id}` (and the list fallback), so resolving + // would lock it out of `databases use` entirely. + if !database_exists_or_unverifiable(get_database(&api, id_or_name)) + .unwrap_or_else(|e| e.exit()) + { + eprintln!( + "{}", + format!("error: no database with id '{id_or_name}'").red() + ); + std::process::exit(1); + } + id_or_name.to_string() + } else { + // A catalog or name: resolve it so `databases use ` works like + // every other database lookup. Config must hold the id — everything + // downstream (X-Database-Id, context paths) sends it raw. + resolve_database(&api, id_or_name).id + }; + if let Err(e) = crate::config::save_current_database("default", workspace_id, &id) { eprintln!("{}", format!("error saving current database: {e}").red()); std::process::exit(1); } @@ -2235,24 +2316,7 @@ pub fn tables_load( let api = Api::new(Some(workspace_id)); // Prefer the active database when its catalog or name matches the lookup key, // avoiding ambiguity when multiple databases share the same catalog name. - let active_id = crate::config::load_current_database("default", workspace_id); - let lookup_key = match active_id.as_deref() { - Some(id) => { - if let Some(active) = none_if_404(get_database(&api, id)).unwrap_or_else(|e| e.exit()) { - if active.default_catalog.as_deref() == Some(database.as_str()) - || active.name.as_deref() == Some(database.as_str()) - { - id.to_string() - } else { - database.clone() - } - } else { - database.clone() - } - } - None => database.clone(), - }; - let db = resolve_database(&api, &lookup_key); + let db = resolve_database_preferring_active(&api, &database); // A result load hits the connection-scoped endpoint, where the server scopes // the result by the X-Database-Id header; that must name the resolved target // database, not the ambient active one (which may be unset or different). An @@ -2646,6 +2710,132 @@ mod tests { ) } + #[test] + fn database_json_names_catalog_ids_as_catalog() { + // runtimedb removed connections as a concept (push-only engine: every + // connection row IS a catalog), so the CLI exposes the ids — they're + // real handles, e.g. for detach — under the catalog name they'll keep. + // Only the wire still says `default_connection_id`. + let db = Database { + id: "db_1".to_string(), + name: Some("sales".to_string()), + default_catalog: Some("sales".to_string()), + default_connection_id: "conn_own".to_string(), + expires_at: None, + created_at: None, + forked_from: None, + attachments: vec![DatabaseAttachment { + connection_id: "conn_a".to_string(), + alias: Some("gh".to_string()), + }], + }; + let json = serde_json::to_value(&db).unwrap(); + assert_eq!(json["default_catalog_id"], serde_json::json!("conn_own")); + assert!(json.get("default_connection_id").is_none(), "json: {json}"); + assert_eq!( + json["attachments"][0]["catalog_id"], + serde_json::json!("conn_a") + ); + assert!( + json["attachments"][0].get("connection_id").is_none(), + "json: {json}" + ); + } + + #[test] + fn create_response_parses_wire_name_but_emits_catalog_id() { + // The server's create/fork responses still say `default_connection_id`; + // the CLI's own -o json/yaml renames it on the way out. + let resp: CreateDatabaseResponse = + serde_json::from_str(r#"{"id":"db_new","default_connection_id":"conn_abc"}"#).unwrap(); + let json = serde_json::to_value(&resp).unwrap(); + assert_eq!(json["default_catalog_id"], serde_json::json!("conn_abc")); + assert!(json.get("default_connection_id").is_none(), "json: {json}"); + } + + /// Like [`full_detail`] but with an explicit catalog, for ambiguity tests. + fn detail_with_catalog(id: &str, name: &str, catalog: &str) -> String { + format!( + r#"{{"id":"{id}","name":"{name}","default_catalog":"{catalog}","default_schema":"main","default_connection_id":"conn_x","attachments":[]}}"# + ) + } + + #[test] + fn preferring_active_picks_active_on_matching_catalog() { + // Two databases could share catalog 'default'; the active one wins + // without a list round-trip. Only the active-detail GET is mocked — a + // fallback into try_resolve_database would hit unmocked routes and fail. + let mut server = mockito::Server::new(); + let active = server + .mock("GET", "/v1/databases/db_active") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(detail_with_catalog("db_active", "mine", "default")) + .create(); + + let api = Api::test_new_scoped(&server.url(), "k", Some("ws"), Some("db_active")); + let db = try_resolve_database_preferring_active(&api, "default").unwrap(); + assert_eq!(db.id, "db_active"); + active.assert(); + } + + #[test] + fn preferring_active_falls_back_when_active_does_not_match() { + // The active database's catalog/name don't match the key → the normal + // id → catalog → name resolution runs. + let mut server = mockito::Server::new(); + let active = server + .mock("GET", "/v1/databases/db_active") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(detail_with_catalog("db_active", "othername", "other")) + .create(); + let not_id = server + .mock("GET", "/v1/databases/sales") + .with_status(404) + .with_body(r#"{"error":"not found"}"#) + .create(); + let list = server + .mock("GET", "/v1/databases") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"databases":[{"id":"db_s","name":"sales","default_catalog":"sales_cat","default_schema":"main"}]}"#, + ) + .create(); + let detail = server + .mock("GET", "/v1/databases/db_s") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(detail_with_catalog("db_s", "sales", "sales_cat")) + .create(); + + let api = Api::test_new_scoped(&server.url(), "k", Some("ws"), Some("db_active")); + let db = try_resolve_database_preferring_active(&api, "sales").unwrap(); + assert_eq!(db.id, "db_s"); + active.assert(); + not_id.assert(); + list.assert(); + detail.assert(); + } + + #[test] + fn preferring_active_without_scope_resolves_plainly() { + // No ambient database scope → straight to the normal resolution. + let mut server = mockito::Server::new(); + let by_id = server + .mock("GET", "/v1/databases/db_abc") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(full_detail("db_abc", "sales", "conn_1")) + .create(); + + let api = Api::test_new(&server.url(), "k", Some("ws")); + let db = try_resolve_database_preferring_active(&api, "db_abc").unwrap(); + assert_eq!(db.id, "db_abc"); + by_id.assert(); + } + #[test] fn detail_forked_from_maps_and_serializes() { // A fork's detail response carries provenance; it must survive the @@ -2678,6 +2868,49 @@ mod tests { by_id.assert(); } + #[test] + fn database_flag_passes_ids_through_without_resolving() { + // An id needs no lookup: point the Api at a server with no mocks so + // any resolve attempt fails loudly. + let server = mockito::Server::new(); + let api = Api::test_new(&server.url(), "k", Some("ws")); + assert_eq!(resolve_database_flag(&api, "dbid123abc"), "dbid123abc"); + assert!(is_database_id("dbideutulm48nc5l28ikc6u53gmjzr")); + assert!(!is_database_id("littlesis")); + assert!(!is_database_id("default")); + } + + #[test] + fn database_flag_resolves_names_to_ids() { + // A non-id flag resolves through the normal lookup and returns the id. + let mut server = mockito::Server::new(); + let not_id = server + .mock("GET", "/v1/databases/sales") + .with_status(404) + .with_body(r#"{"error":"not found"}"#) + .create(); + let list = server + .mock("GET", "/v1/databases") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"databases":[{"id":"db_s","name":"sales","default_catalog":"sales_cat","default_schema":"main"}]}"#, + ) + .create(); + let detail = server + .mock("GET", "/v1/databases/db_s") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(detail_with_catalog("db_s", "sales", "sales_cat")) + .create(); + + let api = Api::test_new(&server.url(), "k", Some("ws")); + assert_eq!(resolve_database_flag(&api, "sales"), "db_s"); + not_id.assert(); + list.assert(); + detail.assert(); + } + #[test] fn database_without_forked_from_omits_it_from_json() { // Originals (and pre-lineage forks) carry no record; the JSON omits the diff --git a/src/commands/queries.rs b/src/commands/queries.rs index 7203d4e..4ba67b0 100644 --- a/src/commands/queries.rs +++ b/src/commands/queries.rs @@ -209,7 +209,9 @@ pub fn list( status: Option<&str>, format: &str, ) { - let api = Api::new(Some(workspace_id)).scoped_to_database_opt(database); + let api = Api::new(Some(workspace_id)); + let database = database.map(|d| crate::commands::databases::resolve_database_flag(&api, d)); + let api = api.scoped_to_database_opt(database.as_deref()); let database_id = api.require_database(); let resp = crate::client::sdk::block_with_wakeup( @@ -269,7 +271,9 @@ pub fn list( } pub fn get(query_run_id: &str, workspace_id: &str, database: Option<&str>, format: &str) { - let api = Api::new(Some(workspace_id)).scoped_to_database_opt(database); + let api = Api::new(Some(workspace_id)); + let database = database.map(|d| crate::commands::databases::resolve_database_flag(&api, d)); + let api = api.scoped_to_database_opt(database.as_deref()); let database_id = api.require_database(); let run: QueryRun = crate::client::sdk::block_with_wakeup( &api, diff --git a/src/commands/query.rs b/src/commands/query.rs index b54c007..89a5472 100644 --- a/src/commands/query.rs +++ b/src/commands/query.rs @@ -425,11 +425,14 @@ fn fail_run(error_msg: &str) -> ! { } pub fn execute(sql: &str, workspace_id: &str, database: Option<&str>, format: &str, dialect: &str) { - // Scope to the explicit --database flag, else the active database resolved - // at construction (HOTDATA_DATABASE / current database). The scoped `Api` - // carries the database into submit_query's `X-Database-Id` header and into - // the database-scoped follow-up fetches (query-run poll, Arrow result). - let api = Api::new(Some(workspace_id)).scoped_to_database_opt(database); + // Scope to the explicit --database flag (an id, catalog, or name — resolved + // to an id here), else the active database resolved at construction + // (HOTDATA_DATABASE / current database). The scoped `Api` carries the + // database into submit_query's `X-Database-Id` header and into the + // database-scoped follow-up fetches (query-run poll, Arrow result). + let api = Api::new(Some(workspace_id)); + let database = database.map(|d| crate::commands::databases::resolve_database_flag(&api, d)); + let api = api.scoped_to_database_opt(database.as_deref()); let database = api.database_id(); let mut request = hotdata::models::QueryRequest::new(sql.to_string()); @@ -535,7 +538,9 @@ pub fn execute(sql: &str, workspace_id: &str, database: Option<&str>, format: &s /// Poll a query run by ID. If succeeded and has a result_id, fetch and display the result. pub fn poll(query_run_id: &str, workspace_id: &str, database: Option<&str>, format: &str) { - let api = Api::new(Some(workspace_id)).scoped_to_database_opt(database); + let api = Api::new(Some(workspace_id)); + let database = database.map(|d| crate::commands::databases::resolve_database_flag(&api, d)); + let api = api.scoped_to_database_opt(database.as_deref()); let run = crate::client::sdk::block( api.client() diff --git a/src/commands/results.rs b/src/commands/results.rs index 8215677..538b59c 100644 --- a/src/commands/results.rs +++ b/src/commands/results.rs @@ -59,7 +59,9 @@ pub fn list( offset: Option, format: &str, ) { - let api = Api::new(Some(workspace_id)).scoped_to_database_opt(database); + let api = Api::new(Some(workspace_id)); + let database = database.map(|d| crate::commands::databases::resolve_database_flag(&api, d)); + let api = api.scoped_to_database_opt(database.as_deref()); // Results are database-scoped (the required `X-Database-Id` header the seam // sends from the active database). Fail early with a hint when none is set, // rather than surfacing the raw server error. @@ -152,7 +154,9 @@ pub fn list( } pub fn get(result_id: &str, workspace_id: &str, database: Option<&str>, format: &str) { - let api = Api::new(Some(workspace_id)).scoped_to_database_opt(database); + let api = Api::new(Some(workspace_id)); + let database = database.map(|d| crate::commands::databases::resolve_database_flag(&api, d)); + let api = api.scoped_to_database_opt(database.as_deref()); let result = crate::commands::query::fetch_arrow_result(&api, result_id); crate::commands::query::print_result(&result, format); } diff --git a/src/commands/search.rs b/src/commands/search.rs index b13b5d9..8f5a30b 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -76,7 +76,7 @@ pub enum SearchCommands { /// Index name name: String, - /// Database the index lives in (id; defaults to the active database) + /// Database the index lives in (id, catalog, or name; defaults to the active database) #[arg(long, short = 'd')] database: Option, @@ -90,7 +90,7 @@ pub enum SearchCommands { /// Index name name: String, - /// Database the index lives in (id; defaults to the active database) + /// Database the index lives in (id, catalog, or name; defaults to the active database) #[arg(long, short = 'd')] database: Option, }, @@ -266,20 +266,25 @@ fn create( // its own error (e.g. an ambiguous forked-catalog alias) is surfaced as-is. let db = match target { FromTarget::Database(db) => *db, - FromTarget::Catalog(catalog) => databases::try_resolve_database(&api, &catalog) - .unwrap_or_else(|e| { + // Prefer the active database when the catalog is ambiguous — the same + // rule `databases load --catalog` applies — so a `--from` naming the + // active database's own catalog (e.g. `default` shared across forks) + // resolves to it instead of erroring. + FromTarget::Catalog(catalog) => { + databases::try_resolve_database_preferring_active(&api, &catalog).unwrap_or_else(|e| { use crossterm::style::Stylize; eprintln!( "{}", format!( - "error: {e}\nSearch indexes are created on instant databases — pass a \ + "error: {e}\nSearch indexes are created on instant databases — pass an \ instant database's catalog or id, or 'schema.table' with an active \ database set via 'hotdata databases use '." ) .red() ); std::process::exit(1); - }), + }) + } }; let conn_id = db.default_connection_id; let auto_name = format!("{table}_{}_{index_type}", column.replace(',', "_")); @@ -321,7 +326,12 @@ fn list(workspace_id: &str, schema: Option<&str>, table: Option<&str>, output: & } fn locate_or_exit(workspace_id: &str, database: Option<&str>, name: &str) -> indexes::LocatedIndex { - indexes::locate_by_name(workspace_id, database, name).unwrap_or_else(|e| { + // `--database` accepts a catalog or name as well as an id, like every + // other database flag; locate_by_name needs the id. Ids short-circuit in + // resolve_database_flag, so the Api built here costs no round trip then. + let database = + database.map(|d| databases::resolve_database_flag(&Api::new(Some(workspace_id)), d)); + indexes::locate_by_name(workspace_id, database.as_deref(), name).unwrap_or_else(|e| { use crossterm::style::Stylize; eprintln!("{}", e.red()); std::process::exit(1); diff --git a/src/commands/workspace.rs b/src/commands/workspace.rs index 5a0ccec..882985a 100644 --- a/src/commands/workspace.rs +++ b/src/commands/workspace.rs @@ -24,6 +24,12 @@ pub enum WorkspaceCommands { struct Workspace { public_id: String, name: String, + /// True for the workspace commands act on by default (the one `workspaces + /// use` selected, or `HOTDATA_WORKSPACE`). Not to be confused with + /// `active`, a server-side workspace-state flag that is true for every + /// usable workspace — scripts looking for "the current workspace" need + /// this field, and before it existed the JSON offered nothing. + default: bool, active: bool, favorite: bool, provision_status: String, @@ -34,6 +40,8 @@ impl From<&hotdata::models::WorkspaceListItem> for Workspace { Workspace { public_id: w.public_id.clone(), name: w.name.clone(), + // Stamped by fetch_workspaces, which knows the configured default. + default: false, active: w.active, favorite: w.favorite, provision_status: w.provision_status.clone(), @@ -41,10 +49,38 @@ impl From<&hotdata::models::WorkspaceListItem> for Workspace { } } +/// The workspace commands act on when none is passed: `HOTDATA_WORKSPACE`, +/// else the loaded profile's default via [`profile_default_workspace_id`]. +fn default_workspace_id() -> String { + std::env::var("HOTDATA_WORKSPACE").unwrap_or_else(|_| { + config::load("default") + .ok() + .and_then(|c| profile_default_workspace_id(&c)) + .unwrap_or_default() + }) +} + +/// The workspace the `default` marker names: resolved by the same helper +/// `main`'s `resolve_workspace` uses, so it's the workspace commands actually +/// hit — NOT the front of the config cache. An `--api-key`/`HOTDATA_API_KEY` +/// credential pins its own authorized workspace, which the cache front (what +/// `workspaces use` saved for a session) need not match or even reach. +fn profile_default_workspace_id(profile: &config::ProfileConfig) -> Option { + crate::client::credentials::default_workspace_id(profile) +} + fn fetch_workspaces() -> Vec { let api = Api::new(None); let body = api.list_workspaces(None).unwrap_or_else(|e| e.exit()); - body.workspaces.iter().map(Workspace::from).collect() + let default_id = default_workspace_id(); + body.workspaces + .iter() + .map(|w| { + let mut ws = Workspace::from(w); + ws.default = !default_id.is_empty() && ws.public_id == default_id; + ws + }) + .collect() } pub fn set(workspace_id: Option<&str>) { @@ -104,21 +140,6 @@ pub fn set(workspace_id: Option<&str>) { } pub fn list(format: &str) { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - 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 workspaces = fetch_workspaces(); match format { @@ -136,7 +157,7 @@ pub fn list(format: &str) { let rows: Vec> = workspaces .iter() .map(|w| { - let marker = if w.public_id == default_id { "*" } else { "" }; + let marker = if w.default { "*" } else { "" }; vec![ marker.to_string(), w.public_id.clone(), @@ -154,3 +175,75 @@ pub fn list(format: &str) { _ => unreachable!(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_json_exposes_default_flag() { + // `active` is a server workspace-state flag (true for every usable + // workspace); `default` is the one the CLI acts on. Scripts need the + // latter in `-o json` — the table view's DEFAULT column has no JSON + // counterpart otherwise. + let ws = Workspace { + public_id: "work123".to_string(), + name: "Default Workspace".to_string(), + default: true, + active: true, + favorite: false, + provision_status: "success".to_string(), + }; + let json = serde_json::to_value(&ws).unwrap(); + assert_eq!(json["default"], serde_json::json!(true)); + assert_eq!(json["active"], serde_json::json!(true)); + } + + #[test] + fn default_follows_the_credential_not_the_config_cache_front() { + // An env/flag api key resolves through credentials::default_workspace_id, + // which pins the credential's own authorized workspace. The config + // cache front (what `workspaces use` saved for a session) may be a + // workspace this key can't even reach; marking it — or, since `list` + // only returns the key's workspaces, marking nothing at all — would + // break the "exactly one default: true" contract scripts rely on. + let (_tmp, _guard) = config::test_helpers::with_temp_config_dir(); + let mut server = mockito::Server::new(); + let probe = server + .mock("GET", "/workspaces") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"workspaces":[{"public_id":"work_only","name":"Only"}]}"#) + .create(); + let profile = config::ProfileConfig { + api_key: Some("hd_dbtoken".to_string()), + api_key_source: config::ApiKeySource::Env, + api_url: config::ApiUrl(Some(server.url())), + app_url: config::AppUrl(Some(server.url())), + workspaces: vec![config::WorkspaceEntry { + public_id: "work_saved".to_string(), + name: "Saved".to_string(), + }], + ..Default::default() + }; + assert_eq!( + profile_default_workspace_id(&profile).as_deref(), + Some("work_only") + ); + probe.assert(); + } + + #[test] + fn from_list_item_defaults_to_not_default() { + // The wire item carries no default marker — it's stamped from local + // config by fetch_workspaces, so the raw mapping must start false. + let item = hotdata::models::WorkspaceListItem::new( + "wid".to_string(), + "n".to_string(), + true, + false, + "success".to_string(), + ); + assert!(!Workspace::from(&item).default); + } +} diff --git a/src/main.rs b/src/main.rs index 064f3ff..76d1a16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -423,16 +423,22 @@ fn main() { } }, Some(DatabasesCommands::Context { database, command }) => { - let database_id = database - .or_else(|| { - config::load_current_database("default", &workspace_id) - }) - .unwrap_or_else(|| { - eprintln!( - "error: no active database. Pass -d/--database or set one with 'hotdata databases use '." - ); - std::process::exit(1); - }); + // The context endpoints take the database id as a path + // segment; resolve a catalog/name flag to it first. The + // Api is only built when there's a flag to resolve. + let database_id = match database.as_deref() { + Some(flag) => Some(databases::resolve_database_flag( + &client::sdk::Api::new(Some(&workspace_id)), + flag, + )), + None => config::load_current_database("default", &workspace_id), + } + .unwrap_or_else(|| { + eprintln!( + "error: no active database. Pass -d/--database or set one with 'hotdata databases use '." + ); + std::process::exit(1); + }); match command { ContextCommands::List { output, prefix } => context::list( &workspace_id,