From 669581626664e66e4460b827f0a0a06f3cd194c0 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 9 Oct 2026 15:19:22 +0000 Subject: [PATCH 1/2] feat: product list, view, create, edit and delete Adds `source-coop product`, the first gh-style command group from #21, over the `/api/v1/products` endpoints from source-cooperative/source.coop#651. A small API client sends the request and turns the API's `{"error": {code, message, field_errors}}` body into a message; the CLI does no validation of its own. Requests carry the login session's access token (or `SOURCE_TOKEN`, if set), and run signed out without one, which is enough to read public products. `create` and `edit` take fields from flags, from a JSON file (`--from-file`), or both. When stdin and stderr are terminals, whatever is still missing is prompted for with defaults: a title from the product ID, the data connections the account can use, and the visibilities the chosen one allows. `edit` with no flags prompts from the product's current values and sends only what changed. `delete` asks whether to keep the data and for the name to be typed back; without a terminal it needs `--yes`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017poyHYEyxWEJct9X8Q71qF --- Cargo.lock | 71 +++- Cargo.toml | 1 + README.md | 17 + src/api.rs | 215 +++++++++++ src/main.rs | 48 +++ src/product.rs | 970 +++++++++++++++++++++++++++++++++++++++++++++++++ src/prompt.rs | 141 +++++++ 7 files changed, 1459 insertions(+), 4 deletions(-) create mode 100644 src/api.rs create mode 100644 src/product.rs create mode 100644 src/prompt.rs diff --git a/Cargo.lock b/Cargo.lock index a611e4f..598c2da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,6 +213,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -303,6 +316,17 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "thiserror 1.0.69", +] + [[package]] name = "digest" version = "0.10.7" @@ -346,6 +370,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -1087,7 +1117,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1108,7 +1138,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1219,7 +1249,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1464,6 +1494,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -1509,6 +1545,7 @@ dependencies = [ "base64", "chrono", "clap", + "dialoguer", "dirs", "keyring", "open", @@ -1583,13 +1620,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1762,6 +1819,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 4ecdc8f..d3b686b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ quick-xml = { version = "0.37", features = ["serialize"] } chrono = { version = "0.4", features = ["serde"] } dirs = "6" keyring = "3" +dialoguer = { version = "0.11", default-features = false } [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3", features = ["apple-native"] } diff --git a/README.md b/README.md index f48bd97..c9c15b6 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,23 @@ Use `--profile` to change the section name. > [!TIP] > The credentials are temporary; re-run after expiry (appending adds a duplicate section — AWS uses the last one, but prune stale sections occasionally). We recommend the utilizing `credential-process` in `~/.aws/config` rather storing temporary credentials in `~/.aws/credentials`. +## Managing products + +`source-coop product` lists, views, creates, edits and deletes products through the source.coop API (`/api/v1`), with the same rules as the web UI: the CLI checks nothing itself and shows the API's errors, field by field. + +```bash +source-coop product list # public products +source-coop product list my-org --json # one account's products, as JSON +source-coop product view my-org/my-product # --web opens it in the browser +source-coop product create my-org/my-product # prompts for the rest +source-coop product edit my-org/my-product --visibility unlisted +source-coop product delete my-org/my-product # asks you to type the name back +``` + +`create` and `edit` take each field as a flag (`--title`, `--description`, `--visibility`, `--data-connection`), from a JSON object with `--from-file PATH` (`-` for stdin), or both, with flags winning. In a terminal, whatever is still missing is asked for, with defaults: a title made from the product ID, the data connections the account can use, and the visibilities the chosen connection allows. `edit` with no flags walks through the product's current values. Without a terminal, or with `SOURCE_PROMPT_DISABLED` set, nothing is asked: the request is sent as given, and `delete` needs `--yes`. + +Reading public products needs no credentials. Anything else acts as whoever ran `source-coop login`: login asks Ory for an access token meant for the API (`--audience`, default `https://source.coop`) and refreshes it as needed. `SOURCE_TOKEN`, if set, is sent instead. `--api-url` (or `SOURCE_API_URL`) points the CLI at another deployment, such as a local `http://localhost:3000`. + ## Credential storage The CLI caches the login session (the Ory refresh, ID and access tokens) and the temporary STS credentials for each role, so that `creds` and API commands work without re-authenticating. The refresh token is kept in the session only: it rotates on use, so every role and every API call refreshes through it, one at a time. diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..10f350b --- /dev/null +++ b/src/api.rs @@ -0,0 +1,215 @@ +//! A thin client for source.coop's `/api/v1`. +//! +//! The CLI validates nothing itself: the API enforces the same rules as the web +//! UI, and this module's job is to send the request and turn the API's error +//! body — `{"error": {"code", "message", "field_errors"?}}` — into a message a +//! person can act on. + +use reqwest::{Method, StatusCode}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fmt; + +/// The API's shared error shape. +#[derive(Debug, Deserialize)] +struct ErrorBody { + error: ApiErrorDetail, +} + +#[derive(Debug, Deserialize)] +struct ApiErrorDetail { + code: String, + message: String, + #[serde(default)] + field_errors: BTreeMap>, +} + +/// A failed API call, with whatever the API said about why. +#[derive(Debug)] +pub struct ApiError { + pub status: Option, + pub code: Option, + pub message: String, + pub field_errors: BTreeMap>, +} + +impl fmt::Display for ApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match (&self.code, self.status) { + (Some(code), Some(status)) => { + write!(f, "{} ({code}, HTTP {})", self.message, status.as_u16())? + } + (None, Some(status)) => write!(f, "{} (HTTP {})", self.message, status.as_u16())?, + _ => write!(f, "{}", self.message)?, + } + for (field, problems) in &self.field_errors { + for problem in problems { + write!(f, "\n {field}: {problem}")?; + } + } + if self.status == Some(StatusCode::UNAUTHORIZED) { + write!(f, "\nThis needs you signed in: run 'source-coop login'.")?; + } + Ok(()) + } +} + +impl From for String { + fn from(e: ApiError) -> Self { + e.to_string() + } +} + +impl ApiError { + fn transport(e: impl fmt::Display) -> Self { + ApiError { + status: None, + code: None, + message: format!("API request failed: {e}"), + field_errors: BTreeMap::new(), + } + } + + /// Build the error from a non-2xx response body, which is the shared error + /// shape when the API produced it and anything at all when something in + /// front of the API did. + fn from_response(status: StatusCode, body: &str) -> Self { + match serde_json::from_str::(body) { + Ok(ErrorBody { error }) => ApiError { + status: Some(status), + code: Some(error.code), + message: error.message, + field_errors: error.field_errors, + }, + Err(_) => ApiError { + status: Some(status), + code: None, + message: status + .canonical_reason() + .unwrap_or("Unexpected response") + .to_string(), + field_errors: BTreeMap::new(), + }, + } + } +} + +pub struct Client { + base: url::Url, + token: Option, + http: reqwest::Client, + verbose: bool, +} + +impl Client { + /// `api_url` is the site's origin (e.g. `https://source.coop`); requests go + /// to `{api_url}/api/v1/...`. + pub fn new(api_url: &str, token: Option, verbose: bool) -> Result { + let mut base = url::Url::parse(api_url).map_err(|e| format!("Invalid API URL: {e}"))?; + base.set_path("/api/v1/"); + Ok(Client { + base, + token: token.filter(|t| !t.trim().is_empty()), + http: reqwest::Client::new(), + verbose, + }) + } + + /// The URL for `segments` under `/api/v1/`, each one percent-encoded. + pub fn url(&self, segments: &[&str]) -> url::Url { + let mut url = self.base.clone(); + url.path_segments_mut() + .expect("an http(s) URL has path segments") + .pop_if_empty() + .extend(segments); + url + } + + pub async fn request( + &self, + method: Method, + url: url::Url, + body: Option<&impl Serialize>, + ) -> Result { + if self.verbose { + let auth = if self.token.is_some() { + " (bearer)" + } else { + "" + }; + eprintln!("[verbose] {method} {url}{auth}"); + } + let mut req = self.http.request(method, url); + if let Some(token) = &self.token { + req = req.bearer_auth(token); + } + if let Some(body) = body { + req = req.json(body); + } + let resp = req.send().await.map_err(ApiError::transport)?; + let status = resp.status(); + let text = resp.text().await.map_err(ApiError::transport)?; + if self.verbose { + eprintln!("[verbose] -> HTTP {}", status.as_u16()); + } + if !status.is_success() { + return Err(ApiError::from_response(status, &text)); + } + serde_json::from_str(&text) + .map_err(|e| ApiError::transport(format!("unreadable response: {e}"))) + } + + pub async fn get(&self, url: url::Url) -> Result { + self.request(Method::GET, url, None::<&()>).await + } +} + +/// One page of a listing. +#[derive(Debug, Deserialize)] +pub struct Page { + pub items: Vec, + pub next_cursor: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn url_encodes_segments_under_api_v1() { + let c = Client::new("https://source.coop", None, false).unwrap(); + assert_eq!( + c.url(&["products", "acct", "a b"]).as_str(), + "https://source.coop/api/v1/products/acct/a%20b" + ); + let c = Client::new("http://localhost:3000/ignored", None, false).unwrap(); + assert_eq!( + c.url(&["products"]).as_str(), + "http://localhost:3000/api/v1/products" + ); + } + + #[test] + fn shows_field_errors_from_the_api() { + let body = r#"{"error":{"code":"invalid","message":"The request is invalid.", + "field_errors":{"title":["A title is required"]}}}"#; + let e = ApiError::from_response(StatusCode::BAD_REQUEST, body); + assert_eq!( + e.to_string(), + "The request is invalid. (invalid, HTTP 400)\n title: A title is required" + ); + } + + #[test] + fn survives_a_body_that_is_not_the_error_shape() { + let e = ApiError::from_response(StatusCode::BAD_GATEWAY, "oops"); + assert_eq!(e.to_string(), "Bad Gateway (HTTP 502)"); + } + + #[test] + fn says_how_to_authenticate_on_401() { + let body = r#"{"error":{"code":"unauthenticated","message":"Sign in first."}}"#; + let e = ApiError::from_response(StatusCode::UNAUTHORIZED, body); + assert!(e.to_string().contains("source-coop login")); + } +} diff --git a/src/main.rs b/src/main.rs index 818657f..1271952 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,9 @@ +mod api; mod cache; mod oidc; mod output; +mod product; +mod prompt; mod session; mod sts; @@ -65,6 +68,20 @@ enum Commands { /// Work with the login session #[command(subcommand)] Auth(AuthCommand), + /// List, view, create, edit and delete products + Product(ProductArgs), +} + +/// A command that calls the source.coop API, as whoever ran `login` (see +/// `api_token`). +#[derive(Parser)] +struct ProductArgs { + /// source.coop site URL; the API is served under its `/api/v1` + #[arg(long, global = true, env = "SOURCE_API_URL", default_value = defaults::API_URL)] + api_url: String, + + #[command(subcommand)] + command: product::ProductCommand, } #[derive(Subcommand)] @@ -177,6 +194,21 @@ async fn main() { std::process::exit(1); } } + Commands::Product(args) => { + let token = api_token(verbose).await; + let result = match api::Client::new(&args.api_url, token, verbose) { + Ok(client) => { + let mut tty = prompt::tty(); + let prompter = tty.as_mut().map(|t| t as &mut dyn prompt::Prompter); + product::run(args.command, &client, &args.api_url, prompter).await + } + Err(e) => Err(e), + }; + if let Err(e) = result { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } } } @@ -330,6 +362,22 @@ async fn mint( } } +/// The bearer for the source.coop API: `SOURCE_TOKEN` if set (for a token +/// obtained some other way), else the login session's access token. Without +/// either, commands run signed out, which is enough to read public products. +async fn api_token(verbose: bool) -> Option { + if let Ok(token) = std::env::var("SOURCE_TOKEN") { + return Some(token); + } + match session::token(session::Want::Access, verbose).await { + Ok(token) => token, + Err(e) => { + eprintln!("Warning: continuing signed out: {e}"); + None + } + } +} + /// Trade the cached refresh token for a new id_token, exchange that for new STS /// credentials, and cache the result (including the rotated refresh token) via `save`. async fn refresh( diff --git a/src/product.rs b/src/product.rs new file mode 100644 index 0000000..a7f4da7 --- /dev/null +++ b/src/product.rs @@ -0,0 +1,970 @@ +//! `source-coop product`: list, view, create, edit and delete products through +//! `/api/v1/products`. + +use crate::api::{Client, Page}; +use crate::prompt::Prompter; +use clap::{Args, Subcommand}; +use reqwest::Method; +use serde_json::{json, Map, Value}; +use std::io::Read; + +#[derive(Subcommand)] +pub enum ProductCommand { + /// List public products, or one account's products + List(ListArgs), + /// Show one product + View(ViewArgs), + /// Create a product + Create(CreateArgs), + /// Change a product's title, description, visibility or state + Edit(EditArgs), + /// Delete a product and, unless --preserve-data, its data + Delete(DeleteArgs), +} + +#[derive(Args)] +pub struct ListArgs { + /// List this account's products (all public products when omitted) + account: Option, + + /// Only products whose title, description or IDs contain this text + #[arg(long, short = 'q')] + search: Option, + + /// Only products with every one of these tags (comma-separated) + #[arg(long)] + tags: Option, + + /// Only featured products + #[arg(long)] + featured: bool, + + /// Maximum number of products to list + #[arg(long, short = 'L', default_value_t = 30)] + limit: usize, + + /// Print the products as JSON + #[arg(long)] + json: bool, +} + +#[derive(Args)] +pub struct ViewArgs { + /// The product, as ACCOUNT/PRODUCT + product: ProductRef, + + /// Open the product's page in the browser instead + #[arg(long, short = 'w')] + web: bool, + + /// Print the product as JSON + #[arg(long)] + json: bool, +} + +#[derive(Args)] +pub struct CreateArgs { + /// The new product, as ACCOUNT/PRODUCT (prompted for when omitted) + product: Option, + + #[arg(long, short = 't')] + title: Option, + + #[arg(long, short = 'd')] + description: Option, + + /// public, unlisted or restricted + #[arg(long)] + visibility: Option, + + /// The data connection that stores the product's data (fixed once created) + #[arg(long = "data-connection")] + data_connection_id: Option, + + /// Read fields from a JSON object in this file (`-` for stdin); flags win + #[arg(long, short = 'F', value_name = "PATH")] + from_file: Option, + + /// Print the new product as JSON + #[arg(long)] + json: bool, +} + +#[derive(Args)] +pub struct EditArgs { + /// The product, as ACCOUNT/PRODUCT + product: ProductRef, + + #[arg(long, short = 't')] + title: Option, + + #[arg(long, short = 'd')] + description: Option, + + /// public, unlisted or restricted + #[arg(long)] + visibility: Option, + + /// Deactivate the product + #[arg(long, conflicts_with = "enable")] + disable: bool, + + /// Reactivate a deactivated product (admins only) + #[arg(long)] + enable: bool, + + /// Read fields from a JSON object in this file (`-` for stdin); flags win + #[arg(long, short = 'F', value_name = "PATH")] + from_file: Option, + + /// Print the edited product as JSON + #[arg(long)] + json: bool, +} + +#[derive(Args)] +pub struct DeleteArgs { + /// The product, as ACCOUNT/PRODUCT + product: ProductRef, + + /// Keep the product's objects in storage + #[arg(long)] + preserve_data: bool, + + /// Skip the confirmation prompt + #[arg(long, short = 'y')] + yes: bool, + + /// Print the deleted product as JSON + #[arg(long)] + json: bool, +} + +/// `ACCOUNT/PRODUCT`, the way products are named everywhere else. +#[derive(Clone, Debug, PartialEq)] +pub struct ProductRef { + pub account_id: String, + pub product_id: String, +} + +impl std::str::FromStr for ProductRef { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.trim_matches('/').split_once('/') { + Some((a, p)) if !a.is_empty() && !p.is_empty() && !p.contains('/') => Ok(ProductRef { + account_id: a.to_string(), + product_id: p.to_string(), + }), + _ => Err(format!("expected ACCOUNT/PRODUCT, got '{s}'")), + } + } +} + +impl std::fmt::Display for ProductRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}", self.account_id, self.product_id) + } +} + +impl ProductRef { + fn web_url(&self, site: &str) -> String { + format!( + "{}/{}/{}", + site.trim_end_matches('/'), + self.account_id, + self.product_id + ) + } +} + +pub async fn run( + cmd: ProductCommand, + client: &Client, + site: &str, + prompter: Option<&mut dyn Prompter>, +) -> Result<(), String> { + match cmd { + ProductCommand::List(args) => list(args, client).await, + ProductCommand::View(args) => view(args, client, site).await, + ProductCommand::Create(args) => create(args, client, prompter).await, + ProductCommand::Edit(args) => edit(args, client, prompter).await, + ProductCommand::Delete(args) => delete(args, client, prompter).await, + } +} + +async fn list(args: ListArgs, client: &Client) -> Result<(), String> { + let mut url = match &args.account { + Some(account) => client.url(&["products", account]), + None => client.url(&["products"]), + }; + { + let mut q = url.query_pairs_mut(); + if let Some(s) = &args.search { + q.append_pair("q", s); + } + if let Some(t) = &args.tags { + q.append_pair("tags", t); + } + if args.featured { + q.append_pair("featured", "true"); + } + } + + // Follow next_cursor until there are enough, asking for no more per page + // than the API's maximum. + let mut items: Vec = vec![]; + let mut cursor: Option = None; + while items.len() < args.limit { + let mut page_url = url.clone(); + { + let mut q = page_url.query_pairs_mut(); + q.append_pair("limit", &(args.limit - items.len()).min(100).to_string()); + if let Some(c) = &cursor { + q.append_pair("cursor", c); + } + } + let page: Page = client.get(page_url).await?; + items.extend(page.items); + cursor = page.next_cursor; + if cursor.is_none() { + break; + } + } + items.truncate(args.limit); + + if args.json { + print_json(&Value::Array(items)); + } else if items.is_empty() { + eprintln!("No products found."); + } else { + print_table(&items); + } + Ok(()) +} + +async fn view(args: ViewArgs, client: &Client, site: &str) -> Result<(), String> { + if args.web { + let url = args.product.web_url(site); + eprintln!("Opening {url} in your browser."); + return open::that(&url).map_err(|e| format!("Couldn't open a browser: {e}")); + } + let product: Value = client.get(product_url(client, &args.product)).await?; + if args.json { + print_json(&product); + } else { + print_product(&product, site); + } + Ok(()) +} + +const VISIBILITIES: [&str; 3] = ["public", "unlisted", "restricted"]; + +async fn create( + args: CreateArgs, + client: &Client, + mut prompter: Option<&mut dyn Prompter>, +) -> Result<(), String> { + let mut body = read_fields(args.from_file.as_deref())?; + insert_some(&mut body, "title", args.title); + insert_some(&mut body, "description", args.description); + insert_some(&mut body, "visibility", args.visibility); + insert_some(&mut body, "data_connection_id", args.data_connection_id); + + let product = match (args.product, prompter.as_deref_mut()) { + (Some(p), _) => p, + (None, Some(ask)) => ask.input("Product (ACCOUNT/PRODUCT)", "", false)?.parse()?, + (None, None) => return Err("Name the product to create, as ACCOUNT/PRODUCT.".into()), + }; + body.insert("product_id".into(), json!(product.product_id)); + + if let Some(ask) = prompter { + prompt_new_product(ask, client, &product, &mut body).await?; + } + + let url = client.url(&["products", &product.account_id]); + let created: Value = client.request(Method::POST, url, Some(&body)).await?; + report(&created, args.json, "Created"); + Ok(()) +} + +/// Ask for each field `body` doesn't have yet. The defaults are the ones the +/// web UI's form starts with, and the choices are the data connections the +/// account could use and the visibilities the chosen one allows; the API still +/// decides what's acceptable. +async fn prompt_new_product( + ask: &mut dyn Prompter, + client: &Client, + product: &ProductRef, + body: &mut Map, +) -> Result<(), String> { + if !body.contains_key("title") { + let title = ask.input("Title", &title_from_id(&product.product_id), false)?; + body.insert("title".into(), json!(title)); + } + if !body.contains_key("description") { + let description = ask.input("Description", "", true)?; + body.insert("description".into(), json!(description)); + } + + let mut allowed: Vec = VISIBILITIES.iter().map(|v| v.to_string()).collect(); + match body.get("data_connection_id").and_then(Value::as_str) { + Some(_) => {} + None => { + let connections = usable_connections(client, &product.account_id).await; + if connections.is_empty() { + let id = ask.input("Data connection ID", "", false)?; + body.insert("data_connection_id".into(), json!(id)); + } else { + let labels: Vec = connections.iter().map(connection_label).collect(); + let picked = &connections[ask.select("Data connection", &labels, 0)?]; + body.insert( + "data_connection_id".into(), + json!(str_field(picked, "data_connection_id")), + ); + if let Some(vs) = picked.get("allowed_visibilities").and_then(Value::as_array) { + let vs: Vec = vs + .iter() + .filter_map(Value::as_str) + .map(String::from) + .collect(); + if !vs.is_empty() { + allowed = vs; + } + } + } + } + } + if !body.contains_key("visibility") { + let default = allowed.iter().position(|v| v == "public").unwrap_or(0); + let picked = ask.select("Visibility", &allowed, default)?; + body.insert("visibility".into(), json!(allowed[picked])); + } + Ok(()) +} + +/// The data connections a product of `account_id` could be stored on: the +/// unowned ones and the account's own. Empty if they can't be listed, in which +/// case the caller asks for an ID instead. +async fn usable_connections(client: &Client, account_id: &str) -> Vec { + let all: Vec = match client.get(client.url(&["data-connections"])).await { + Ok(all) => all, + Err(_) => return vec![], + }; + all.into_iter() + .filter(|c| match c.get("owner").and_then(Value::as_str) { + None => true, + Some(owner) => owner == account_id, + }) + .collect() +} + +fn connection_label(c: &Value) -> String { + let name = str_field(c, "name"); + let id = str_field(c, "data_connection_id"); + let ro = if c.get("read_only").and_then(Value::as_bool) == Some(true) { + ", read-only" + } else { + "" + }; + format!("{name} ({id}{ro})") +} + +/// `my-new-product` → `My New Product`. +fn title_from_id(id: &str) -> String { + id.split(['-', '_']) + .filter(|w| !w.is_empty()) + .map(|w| { + let mut cs = w.chars(); + cs.next() + .map(|c| c.to_uppercase().chain(cs).collect::()) + .unwrap_or_default() + }) + .collect::>() + .join(" ") +} + +async fn edit( + args: EditArgs, + client: &Client, + prompter: Option<&mut dyn Prompter>, +) -> Result<(), String> { + let mut body = read_fields(args.from_file.as_deref())?; + insert_some(&mut body, "title", args.title); + insert_some(&mut body, "description", args.description); + insert_some(&mut body, "visibility", args.visibility); + if args.disable || args.enable { + body.insert("disabled".into(), json!(args.disable)); + } + let url = product_url(client, &args.product); + + if body.is_empty() { + let Some(ask) = prompter else { + return Err( + "Nothing to change: pass --title, --description, --visibility, --disable, --enable or --from-file." + .into(), + ); + }; + let current: Value = client.get(url.clone()).await?; + prompt_edits(ask, ¤t, &mut body)?; + if body.is_empty() { + eprintln!("No changes to {}.", args.product); + return Ok(()); + } + } + + let product: Value = client.request(Method::PATCH, url, Some(&body)).await?; + report(&product, args.json, "Edited"); + Ok(()) +} + +/// Walk through the editable fields with their current values as defaults, +/// keeping only the ones that changed. +fn prompt_edits( + ask: &mut dyn Prompter, + current: &Value, + body: &mut Map, +) -> Result<(), String> { + for (key, label, allow_empty) in [ + ("title", "Title", false), + ("description", "Description", true), + ] { + let was = str_field(current, key); + let now = ask.input(label, was, allow_empty)?; + if now != was { + body.insert(key.into(), json!(now)); + } + } + let was = str_field(current, "visibility"); + let options: Vec = VISIBILITIES.iter().map(|v| v.to_string()).collect(); + let default = options.iter().position(|v| v == was).unwrap_or(0); + let now = &options[ask.select("Visibility", &options, default)?]; + if now != was { + body.insert("visibility".into(), json!(now)); + } + Ok(()) +} + +async fn delete( + args: DeleteArgs, + client: &Client, + prompter: Option<&mut dyn Prompter>, +) -> Result<(), String> { + let mut preserve_data = args.preserve_data; + if !args.yes { + let Some(ask) = prompter else { + return Err(format!( + "Refusing to delete {} without confirmation: pass --yes.", + args.product + )); + }; + if !preserve_data { + preserve_data = ask.confirm("Keep the product's data in storage?", false)?; + } + let data = if preserve_data { + "its data will be kept" + } else { + "its data will be deleted too" + }; + eprintln!("This deletes {} and {data}.", args.product); + let typed = ask.input(&format!("Type {} to confirm", args.product), "", true)?; + if typed.trim() != args.product.to_string() { + return Err("Not confirmed; nothing was deleted.".into()); + } + } + let mut url = product_url(client, &args.product); + url.query_pairs_mut() + .append_pair("preserve_data", &preserve_data.to_string()); + let product: Value = client.request(Method::DELETE, url, None::<&()>).await?; + report(&product, args.json, "Deleted"); + Ok(()) +} + +/// The JSON object in `path` (`-` for stdin), or an empty one. Its fields are +/// sent as they are; the API says if any is wrong. +fn read_fields(path: Option<&str>) -> Result, String> { + let Some(path) = path else { + return Ok(Map::new()); + }; + let text = if path == "-" { + let mut s = String::new(); + std::io::stdin() + .read_to_string(&mut s) + .map_err(|e| format!("Couldn't read stdin: {e}"))?; + s + } else { + std::fs::read_to_string(path).map_err(|e| format!("Couldn't read {path}: {e}"))? + }; + match serde_json::from_str(&text) { + Ok(Value::Object(fields)) => Ok(fields), + Ok(_) => Err(format!("{path} must hold a JSON object")), + Err(e) => Err(format!("{path} isn't JSON: {e}")), + } +} + +fn product_url(client: &Client, p: &ProductRef) -> url::Url { + client.url(&["products", &p.account_id, &p.product_id]) +} + +fn insert_some(body: &mut Map, key: &str, value: Option) { + if let Some(v) = value { + body.insert(key.into(), Value::String(v)); + } +} + +fn report(product: &Value, as_json: bool, verb: &str) { + if as_json { + print_json(product); + } else { + eprintln!("{verb} {}", name(product)); + } +} + +fn str_field<'a>(v: &'a Value, key: &str) -> &'a str { + v.get(key).and_then(Value::as_str).unwrap_or("") +} + +fn name(product: &Value) -> String { + format!( + "{}/{}", + str_field(product, "account_id"), + str_field(product, "product_id") + ) +} + +fn print_json(v: &Value) { + println!("{}", serde_json::to_string_pretty(v).unwrap()); +} + +fn print_product(p: &Value, site: &str) { + let name = name(p); + let mut state = str_field(p, "visibility").to_string(); + if p.get("disabled").and_then(Value::as_bool) == Some(true) { + state.push_str(", deactivated"); + } + println!("{}", str_field(p, "title")); + println!("{name} ({state})"); + let description = str_field(p, "description"); + if !description.is_empty() { + println!("\n{description}"); + } + println!("\n{}/{name}", site.trim_end_matches('/')); +} + +/// Rows of `NAME VISIBILITY TITLE`, padded to line up. +fn table_rows(items: &[Value]) -> Vec { + let rows: Vec<[String; 3]> = items + .iter() + .map(|p| { + [ + name(p), + str_field(p, "visibility").to_string(), + str_field(p, "title").to_string(), + ] + }) + .collect(); + let w0 = rows.iter().map(|r| r[0].chars().count()).max().unwrap_or(0); + let w1 = rows.iter().map(|r| r[1].chars().count()).max().unwrap_or(0); + rows.iter() + .map(|[n, v, t]| format!("{n: Value { + json!({"account_id": account, "product_id": id, "title": format!("{id} title"), + "description": "", "visibility": "public", "disabled": false}) + } + + fn create_args(product: Option<&str>) -> CreateArgs { + CreateArgs { + product: product.map(|p| p.parse().unwrap()), + title: None, + description: None, + visibility: None, + data_connection_id: None, + from_file: None, + json: false, + } + } + + fn edit_args() -> EditArgs { + EditArgs { + product: "acct/prod".parse().unwrap(), + title: None, + description: None, + visibility: None, + disable: false, + enable: false, + from_file: None, + json: false, + } + } + + fn delete_args(yes: bool, preserve_data: bool) -> DeleteArgs { + DeleteArgs { + product: "acct/prod".parse().unwrap(), + preserve_data, + yes, + json: false, + } + } + + /// Answer a POST to the account's products with the product, but only if + /// the body is exactly `expected`. + async fn expect_create(server: &MockServer, expected: Value) { + Mock::given(method("POST")) + .and(path("/api/v1/products/acct")) + .and(body_json(expected)) + .respond_with(ResponseTemplate::new(201).set_body_json(product("acct", "prod"))) + .expect(1) + .mount(server) + .await; + } + + #[test] + fn parses_account_slash_product() { + let r: ProductRef = "acct/prod".parse().unwrap(); + assert_eq!(r.to_string(), "acct/prod"); + assert_eq!("/acct/prod/".parse::().unwrap(), r); + for bad in ["acct", "acct/", "/prod", "a/b/c", ""] { + assert!(bad.parse::().is_err(), "accepted {bad:?}"); + } + } + + #[test] + fn titles_come_from_ids() { + assert_eq!(title_from_id("my-new_product"), "My New Product"); + assert_eq!(title_from_id("x--y"), "X Y"); + } + + #[test] + fn table_lines_up() { + let rows = table_rows(&[product("a", "one"), product("longer", "two")]); + assert_eq!( + rows, + [ + "a/one public one title", + "longer/two public two title" + ] + ); + } + + #[test] + fn reads_fields_from_a_file() { + let dir = std::env::temp_dir().join(format!("scc-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("p.json"); + std::fs::write(&f, r#"{"title": "From file", "visibility": "unlisted"}"#).unwrap(); + let fields = read_fields(Some(f.to_str().unwrap())).unwrap(); + assert_eq!(fields["title"], "From file"); + std::fs::write(&f, "[1]").unwrap(); + assert!(read_fields(Some(f.to_str().unwrap())) + .unwrap_err() + .contains("JSON object")); + std::fs::remove_dir_all(dir).ok(); + } + + #[tokio::test] + async fn list_follows_cursors_up_to_the_limit() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/products/acct")) + .and(query_param("cursor", "c1")) + .and(query_param("limit", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json( + json!({"items": [product("acct", "b"), product("acct", "c")], "next_cursor": "c2"}), + )) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/products/acct")) + .and(query_param("limit", "3")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"items": [product("acct", "a")], "next_cursor": "c1"})), + ) + .expect(1) + .mount(&server) + .await; + + let client = Client::new(&server.uri(), None, false).unwrap(); + let args = ListArgs { + account: Some("acct".into()), + search: None, + tags: None, + featured: false, + limit: 3, + json: true, + }; + list(args, &client).await.unwrap(); + } + + #[tokio::test] + async fn create_without_a_terminal_sends_only_what_it_was_given() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v1/products/acct")) + .and(header("authorization", "Bearer tkn")) + .and(body_json(json!({"product_id": "prod", "title": "T"}))) + .respond_with(ResponseTemplate::new(201).set_body_json(product("acct", "prod"))) + .expect(1) + .mount(&server) + .await; + + let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); + let mut args = create_args(Some("acct/prod")); + args.title = Some("T".into()); + create(args, &client, None).await.unwrap(); + } + + #[tokio::test] + async fn create_without_a_terminal_needs_the_product_named() { + let client = Client::new("http://127.0.0.1:9", None, false).unwrap(); + let err = create(create_args(None), &client, None).await.unwrap_err(); + assert!(err.contains("ACCOUNT/PRODUCT")); + } + + #[tokio::test] + async fn create_prompts_for_what_is_missing_with_defaults() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/data-connections")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + {"data_connection_id": "theirs", "name": "Theirs", "owner": "someone-else", + "allowed_visibilities": ["public"]}, + {"data_connection_id": "shared", "name": "Shared", "read_only": false, + "allowed_visibilities": ["public", "unlisted"]}, + {"data_connection_id": "mine", "name": "Mine", "owner": "acct", "read_only": true, + "allowed_visibilities": ["restricted", "public"]}, + ]))) + .mount(&server) + .await; + expect_create( + &server, + json!({"product_id": "my-data", "title": "My Data", "description": "", + "data_connection_id": "mine", "visibility": "public"}), + ) + .await; + + let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); + let mut ask = Script::new([Text("acct/my-data"), Default, Default, Pick(1), Default]); + create(create_args(None), &client, Some(&mut ask)) + .await + .unwrap(); + assert_eq!( + ask.asked, + [ + "Product (ACCOUNT/PRODUCT) []", + "Title [My Data]", + "Description []", + // Someone else's connection isn't offered. + "Data connection [Shared (shared)] of Shared (shared) | Mine (mine, read-only)", + // Only what the chosen connection allows, starting on public. + "Visibility [public] of restricted | public", + ] + ); + } + + #[tokio::test] + async fn create_prompts_only_for_what_flags_and_file_leave_out() { + let server = MockServer::start().await; + expect_create( + &server, + json!({"product_id": "prod", "title": "Flag wins", "description": "From file", + "data_connection_id": "dc", "visibility": "unlisted"}), + ) + .await; + + let dir = std::env::temp_dir().join(format!("scc-create-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("p.json"); + std::fs::write( + &f, + r#"{"title": "From file", "description": "From file", "data_connection_id": "dc"}"#, + ) + .unwrap(); + + let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); + let mut args = create_args(Some("acct/prod")); + args.title = Some("Flag wins".into()); + args.from_file = Some(f.to_str().unwrap().into()); + let mut ask = Script::new([Pick(1)]); + create(args, &client, Some(&mut ask)).await.unwrap(); + assert_eq!( + ask.asked, + ["Visibility [public] of public | unlisted | restricted"] + ); + std::fs::remove_dir_all(dir).ok(); + } + + #[tokio::test] + async fn create_asks_for_a_connection_id_when_none_can_be_listed() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/data-connections")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + expect_create( + &server, + json!({"product_id": "prod", "title": "T", "description": "D", + "data_connection_id": "typed", "visibility": "public"}), + ) + .await; + + let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); + let mut ask = Script::new([Text("T"), Text("D"), Text("typed"), Default]); + create(create_args(Some("acct/prod")), &client, Some(&mut ask)) + .await + .unwrap(); + } + + #[tokio::test] + async fn create_surfaces_the_apis_field_errors() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v1/products/acct")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({"error": { + "code": "invalid", "message": "The request is invalid.", + "field_errors": {"data_connection_id": ["A data connection is required"]}}}))) + .mount(&server) + .await; + + let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); + let err = create(create_args(Some("acct/prod")), &client, None) + .await + .unwrap_err(); + assert!(err.contains("data_connection_id: A data connection is required")); + } + + #[tokio::test] + async fn edit_sends_disabled_and_without_a_terminal_refuses_an_empty_patch() { + let server = MockServer::start().await; + Mock::given(method("PATCH")) + .and(path("/api/v1/products/acct/prod")) + .and(body_json(json!({"disabled": true}))) + .respond_with(ResponseTemplate::new(200).set_body_json(product("acct", "prod"))) + .expect(1) + .mount(&server) + .await; + let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); + + let mut args = edit_args(); + args.disable = true; + edit(args, &client, None).await.unwrap(); + assert!(edit(edit_args(), &client, None) + .await + .unwrap_err() + .contains("Nothing to change")); + } + + #[tokio::test] + async fn edit_prompts_from_current_values_and_sends_only_changes() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/products/acct/prod")) + .respond_with(ResponseTemplate::new(200).set_body_json(product("acct", "prod"))) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/api/v1/products/acct/prod")) + .and(body_json( + json!({"description": "New", "visibility": "unlisted"}), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(product("acct", "prod"))) + .expect(1) + .mount(&server) + .await; + + let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); + let mut ask = Script::new([Default, Text("New"), Pick(1)]); + edit(edit_args(), &client, Some(&mut ask)).await.unwrap(); + assert_eq!(ask.asked[0], "Title [prod title]"); + } + + #[tokio::test] + async fn edit_with_nothing_changed_sends_nothing() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/products/acct/prod")) + .respond_with(ResponseTemplate::new(200).set_body_json(product("acct", "prod"))) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); + let mut ask = Script::new([Default, Default, Default]); + edit(edit_args(), &client, Some(&mut ask)).await.unwrap(); + } + + /// Answer a DELETE of acct/prod, but only with this `preserve_data`. + async fn expect_delete(server: &MockServer, preserve_data: &str, times: u64) { + Mock::given(method("DELETE")) + .and(path("/api/v1/products/acct/prod")) + .and(query_param("preserve_data", preserve_data)) + .respond_with(ResponseTemplate::new(200).set_body_json(product("acct", "prod"))) + .expect(times) + .mount(server) + .await; + } + + #[tokio::test] + async fn delete_with_yes_passes_preserve_data() { + let server = MockServer::start().await; + expect_delete(&server, "true", 1).await; + let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); + delete(delete_args(true, true), &client, None) + .await + .unwrap(); + } + + #[tokio::test] + async fn delete_without_a_terminal_needs_yes() { + let client = Client::new("http://127.0.0.1:9", None, false).unwrap(); + let err = delete(delete_args(false, false), &client, None) + .await + .unwrap_err(); + assert!(err.contains("--yes")); + } + + #[tokio::test] + async fn delete_asks_about_data_then_for_the_name() { + let server = MockServer::start().await; + expect_delete(&server, "true", 1).await; + let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); + let mut ask = Script::new([Yes(true), Text("acct/prod")]); + delete(delete_args(false, false), &client, Some(&mut ask)) + .await + .unwrap(); + } + + #[tokio::test] + async fn delete_stops_on_the_wrong_name() { + let server = MockServer::start().await; + expect_delete(&server, "false", 0).await; + let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); + let mut ask = Script::new([Default, Text("acct/other")]); + let err = delete(delete_args(false, false), &client, Some(&mut ask)) + .await + .unwrap_err(); + assert!(err.contains("nothing was deleted")); + } +} diff --git a/src/prompt.rs b/src/prompt.rs new file mode 100644 index 0000000..08f39bf --- /dev/null +++ b/src/prompt.rs @@ -0,0 +1,141 @@ +//! Interactive prompts for whatever a command wasn't given by flag or file. +//! +//! Prompts appear only when someone is there to answer them: stdin and stderr +//! are both terminals and `SOURCE_PROMPT_DISABLED` is unset. Otherwise a +//! command sends what it was given, and the API says what's missing. + +use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; +use std::io::IsTerminal; + +pub trait Prompter { + /// Ask for a line of text, offering `default` (taken on a bare Enter). + fn input(&mut self, label: &str, default: &str, allow_empty: bool) -> Result; + /// Ask for one of `items`, starting on `default`; returns its index. + fn select(&mut self, label: &str, items: &[String], default: usize) -> Result; + /// Ask a yes/no question. + fn confirm(&mut self, label: &str, default: bool) -> Result; +} + +/// Prompts on the terminal, written to stderr so stdout stays clean for output. +pub struct Tty { + theme: ColorfulTheme, +} + +/// A terminal prompter, or None when no one is there to answer. +pub fn tty() -> Option { + let enabled = std::env::var_os("SOURCE_PROMPT_DISABLED").is_none() + && std::io::stdin().is_terminal() + && std::io::stderr().is_terminal(); + enabled.then(|| Tty { + theme: ColorfulTheme::default(), + }) +} + +fn failed(e: dialoguer::Error) -> String { + format!("Prompt failed: {e}") +} + +impl Prompter for Tty { + fn input(&mut self, label: &str, default: &str, allow_empty: bool) -> Result { + let mut input = Input::::with_theme(&self.theme) + .with_prompt(label) + .allow_empty(allow_empty); + if !default.is_empty() { + input = input.default(default.to_string()); + } + input.interact_text().map_err(failed) + } + + fn select(&mut self, label: &str, items: &[String], default: usize) -> Result { + Select::with_theme(&self.theme) + .with_prompt(label) + .items(items) + .default(default) + .interact() + .map_err(failed) + } + + fn confirm(&mut self, label: &str, default: bool) -> Result { + Confirm::with_theme(&self.theme) + .with_prompt(label) + .default(default) + .interact() + .map_err(failed) + } +} + +/// Answers from a script, in order, for tests. +#[cfg(test)] +pub mod scripted { + use super::Prompter; + use std::collections::VecDeque; + + #[derive(Debug)] + pub enum Answer { + /// Take the offered default. + Default, + Text(&'static str), + Pick(usize), + Yes(bool), + } + + #[derive(Default)] + pub struct Script { + pub answers: VecDeque, + /// Every prompt asked, as `label [default]`, to check what was offered. + pub asked: Vec, + } + + impl Script { + pub fn new(answers: impl IntoIterator) -> Self { + Script { + answers: answers.into_iter().collect(), + asked: vec![], + } + } + + fn next(&mut self, label: &str) -> Answer { + self.answers + .pop_front() + .unwrap_or_else(|| panic!("unscripted prompt: {label}")) + } + } + + impl Prompter for Script { + fn input(&mut self, label: &str, default: &str, _: bool) -> Result { + self.asked.push(format!("{label} [{default}]")); + Ok(match self.next(label) { + Answer::Default => default.to_string(), + Answer::Text(t) => t.to_string(), + a => panic!("{label}: expected text, scripted {a:?}"), + }) + } + + fn select( + &mut self, + label: &str, + items: &[String], + default: usize, + ) -> Result { + self.asked.push(format!( + "{label} [{}] of {}", + items[default], + items.join(" | ") + )); + Ok(match self.next(label) { + Answer::Default => default, + Answer::Pick(i) => i, + a => panic!("{label}: expected a pick, scripted {a:?}"), + }) + } + + fn confirm(&mut self, label: &str, default: bool) -> Result { + self.asked.push(format!("{label} [{default}]")); + Ok(match self.next(label) { + Answer::Default => default, + Answer::Yes(y) => y, + a => panic!("{label}: expected yes/no, scripted {a:?}"), + }) + } + } +} From bbe48fec52d98316f935b35dbffbecd562173b22 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 9 Oct 2026 16:02:26 +0000 Subject: [PATCH 2/2] feat: re-asking, editor, script-friendly output and `source-coop api` When the API rejects a field and someone is at the terminal, the CLI shows why and asks for just that field again, keeping every other answer, then resends; the API stays the only judge. A rejection naming a field nobody can be asked for is an error, as before. Descriptions can be written in `$VISUAL`/`$EDITOR`, gh's way: Enter keeps, `e` opens the editor, anything else is the text. `edit` with no flags first asks which fields to change. `list` prints a header and aligned columns on a terminal, and tab-separated rows when piped. `create` and `edit` print the product URL on stdout and their message on stderr. A request that gets no response now says why (DNS, TLS, refused connection), not just which URL. `source-coop api PATH` sends any `/api/v1` request as the signed-in user, with gh-style `-X`, `-f`, `-F` and `--input`, prints the response, and exits non-zero on an error status. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017poyHYEyxWEJct9X8Q71qF --- Cargo.lock | 39 +++++++ Cargo.toml | 2 +- README.md | 17 ++- src/api.rs | 82 ++++++++++++-- src/api_cmd.rs | 192 ++++++++++++++++++++++++++++++++ src/main.rs | 24 ++++ src/product.rs | 292 +++++++++++++++++++++++++++++++++++++++---------- src/prompt.rs | 58 +++++++++- 8 files changed, 637 insertions(+), 69 deletions(-) create mode 100644 src/api_cmd.rs diff --git a/Cargo.lock b/Cargo.lock index 598c2da..b2b4e4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,6 +324,7 @@ checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" dependencies = [ "console", "shell-words", + "tempfile", "thiserror 1.0.69", ] @@ -392,6 +393,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -921,6 +928,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -1339,6 +1352,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1620,6 +1646,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index d3b686b..8197db0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ quick-xml = { version = "0.37", features = ["serialize"] } chrono = { version = "0.4", features = ["serde"] } dirs = "6" keyring = "3" -dialoguer = { version = "0.11", default-features = false } +dialoguer = { version = "0.11", default-features = false, features = ["editor"] } [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3", features = ["apple-native"] } diff --git a/README.md b/README.md index c9c15b6..94a1174 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,22 @@ source-coop product edit my-org/my-product --visibility unlisted source-coop product delete my-org/my-product # asks you to type the name back ``` -`create` and `edit` take each field as a flag (`--title`, `--description`, `--visibility`, `--data-connection`), from a JSON object with `--from-file PATH` (`-` for stdin), or both, with flags winning. In a terminal, whatever is still missing is asked for, with defaults: a title made from the product ID, the data connections the account can use, and the visibilities the chosen connection allows. `edit` with no flags walks through the product's current values. Without a terminal, or with `SOURCE_PROMPT_DISABLED` set, nothing is asked: the request is sent as given, and `delete` needs `--yes`. +`create` and `edit` take each field as a flag (`--title`, `--description`, `--visibility`, `--data-connection`), from a JSON object with `--from-file PATH` (`-` for stdin), or both, with flags winning. In a terminal, whatever is still missing is asked for, with defaults: a title made from the product ID, the data connections the account can use, and the visibilities the chosen connection allows. A description can be typed on one line, or written in your editor (`$VISUAL` or `$EDITOR`) by answering `e`. `edit` with no flags asks which fields to change and starts each from its current value. When the API rejects a field, you see why and are asked for just that field again. Without a terminal, or with `SOURCE_PROMPT_DISABLED` set, nothing is asked: the request is sent as given, and `delete` needs `--yes`. + +Output is for people on a terminal and for scripts when piped: `list` prints an aligned table with a header on a terminal, and tab-separated rows without one when piped; `create` and `edit` print the product's URL on stdout and their message on stderr, so `url=$(source-coop product create ...)` works. + +### Any API request + +`source-coop api` sends any request to `/api/v1`, signed in as you, and prints the response. It covers what the other commands don't yet: + +```bash +source-coop api products/my-org -X GET -F limit=5 +source-coop api products/my-org -f product_id=new -f title="New" -f description="" \ + -f visibility=public -f data_connection_id=my-connection +source-coop api products/my-org/new -X PATCH --input changes.json +``` + +`-f key=value` sends a string, and `-F key=value` sends `true`, `false`, `null` and numbers as JSON. Fields go in the query string for `GET` and in a JSON body otherwise, and the method is `POST` when fields or `--input` are given. A status other than success exits non-zero. Reading public products needs no credentials. Anything else acts as whoever ran `source-coop login`: login asks Ory for an access token meant for the API (`--audience`, default `https://source.coop`) and refreshes it as needed. `SOURCE_TOKEN`, if set, is sent instead. `--api-url` (or `SOURCE_API_URL`) points the CLI at another deployment, such as a local `http://localhost:3000`. diff --git a/src/api.rs b/src/api.rs index 10f350b..2995b05 100644 --- a/src/api.rs +++ b/src/api.rs @@ -61,11 +61,24 @@ impl From for String { } impl ApiError { - fn transport(e: impl fmt::Display) -> Self { + /// A request that never got a response. reqwest's own message names only + /// the URL; the cause (DNS, TLS, a refused connection) is further down the + /// chain, so all of it is shown. + fn transport(e: &reqwest::Error) -> Self { + let mut message = format!("API request failed: {e}"); + let mut source = std::error::Error::source(e); + while let Some(cause) = source { + message.push_str(&format!(": {cause}")); + source = cause.source(); + } + Self::other(message) + } + + fn other(message: String) -> Self { ApiError { status: None, code: None, - message: format!("API request failed: {e}"), + message, field_errors: BTreeMap::new(), } } @@ -73,7 +86,7 @@ impl ApiError { /// Build the error from a non-2xx response body, which is the shared error /// shape when the API produced it and anything at all when something in /// front of the API did. - fn from_response(status: StatusCode, body: &str) -> Self { + pub fn from_response(status: StatusCode, body: &str) -> Self { match serde_json::from_str::(body) { Ok(ErrorBody { error }) => ApiError { status: Some(status), @@ -125,12 +138,14 @@ impl Client { url } - pub async fn request( + /// Send a request and return the status and body as they came, whatever + /// the status. Only a request that gets no response at all is an error. + pub async fn send( &self, method: Method, url: url::Url, body: Option<&impl Serialize>, - ) -> Result { + ) -> Result<(StatusCode, String), ApiError> { if self.verbose { let auth = if self.token.is_some() { " (bearer)" @@ -146,17 +161,40 @@ impl Client { if let Some(body) = body { req = req.json(body); } - let resp = req.send().await.map_err(ApiError::transport)?; + let resp = req.send().await.map_err(|e| ApiError::transport(&e))?; let status = resp.status(); - let text = resp.text().await.map_err(ApiError::transport)?; + let text = resp.text().await.map_err(|e| ApiError::transport(&e))?; if self.verbose { eprintln!("[verbose] -> HTTP {}", status.as_u16()); } + Ok((status, text)) + } + + /// Send a request and parse a successful response; any other status is an + /// error carrying what the API said. + pub async fn request( + &self, + method: Method, + url: url::Url, + body: Option<&impl Serialize>, + ) -> Result { + let (status, text) = self.send(method, url, body).await?; if !status.is_success() { return Err(ApiError::from_response(status, &text)); } serde_json::from_str(&text) - .map_err(|e| ApiError::transport(format!("unreadable response: {e}"))) + .map_err(|e| ApiError::other(format!("API response unreadable: {e}"))) + } + + /// Resolve a path the way `source-coop api` takes one: relative to + /// `/api/v1/`, with or without a leading `/` or `api/v1/`, and with any + /// query string kept. + pub fn resolve(&self, path: &str) -> Result { + let path = path.trim_start_matches('/'); + let path = path.strip_prefix("api/v1/").unwrap_or(path); + self.base + .join(path) + .map_err(|e| format!("Invalid API path '{path}': {e}")) } pub async fn get(&self, url: url::Url) -> Result { @@ -189,6 +227,34 @@ mod tests { ); } + #[test] + fn resolves_api_paths() { + let c = Client::new("https://source.coop", None, false).unwrap(); + for p in [ + "products/acct?limit=2", + "/products/acct?limit=2", + "/api/v1/products/acct?limit=2", + ] { + assert_eq!( + c.resolve(p).unwrap().as_str(), + "https://source.coop/api/v1/products/acct?limit=2" + ); + } + } + + #[tokio::test] + async fn a_failed_connection_says_why() { + // Nothing listens on port 9 (discard) on loopback. + let c = Client::new("http://127.0.0.1:9", None, false).unwrap(); + let e = c.get::<()>(c.url(&["products"])).await.unwrap_err(); + let msg = e.to_string(); + assert!( + msg.starts_with("API request failed: error sending request"), + "{msg}" + ); + assert!(msg.matches(": ").count() >= 2, "no cause in: {msg}"); + } + #[test] fn shows_field_errors_from_the_api() { let body = r#"{"error":{"code":"invalid","message":"The request is invalid.", diff --git a/src/api_cmd.rs b/src/api_cmd.rs new file mode 100644 index 0000000..6c85154 --- /dev/null +++ b/src/api_cmd.rs @@ -0,0 +1,192 @@ +//! `source-coop api`: any `/api/v1` request, authenticated as whoever ran +//! `login`, for whatever the other commands don't cover yet. Modeled on +//! `gh api`. + +use crate::api::{ApiError, Client}; +use clap::Args; +use reqwest::Method; +use serde_json::{Map, Value}; +use std::io::Read; + +#[derive(Args)] +pub struct ApiArgs { + /// The path under /api/v1, e.g. `products/my-org` (a query string is kept) + path: String, + + /// HTTP method; GET, or POST when fields or --input are given + #[arg(long, short = 'X')] + method: Option, + + /// A field, `key=value`, sent as a string; in the query string for GET + #[arg(long = "raw-field", short = 'f', value_name = "KEY=VALUE")] + raw_fields: Vec, + + /// A field, `key=value`, where `true`, `false`, `null` and numbers are + /// sent as JSON rather than strings + #[arg(long = "field", short = 'F', value_name = "KEY=VALUE")] + fields: Vec, + + /// Send this file's JSON as the request body (`-` for stdin) + #[arg(long, value_name = "PATH", conflicts_with_all = ["raw_fields", "fields"])] + input: Option, +} + +/// Prints the response body (pretty if JSON) to stdout, and fails on any +/// status that isn't a success, as `gh api` does, so scripts can check `$?`. +pub async fn run(args: ApiArgs, client: &Client) -> Result<(), String> { + let mut url = client.resolve(&args.path)?; + let fields = parse_fields(&args.raw_fields, &args.fields)?; + let body = match &args.input { + Some(path) => Some(read_json(path)?), + None => None, + }; + let method = match &args.method { + Some(m) => m + .to_uppercase() + .parse::() + .map_err(|_| format!("Invalid method '{m}'"))?, + None if body.is_some() || !fields.is_empty() => Method::POST, + None => Method::GET, + }; + + let body = if method == Method::GET { + let mut q = url.query_pairs_mut(); + for (k, v) in &fields { + q.append_pair(k, &v.as_str().map_or_else(|| v.to_string(), String::from)); + } + drop(q); + body + } else if fields.is_empty() { + body + } else { + Some(Value::Object(fields)) + }; + + let (status, text) = client.send(method, url, body.as_ref()).await?; + match serde_json::from_str::(&text) { + Ok(json) => println!("{}", serde_json::to_string_pretty(&json).unwrap()), + Err(_) if text.is_empty() => {} + Err(_) => println!("{text}"), + } + if status.is_success() { + Ok(()) + } else { + Err(ApiError::from_response(status, &text).to_string()) + } +} + +/// `-f` values are strings; `-F` values are JSON when they parse as a +/// boolean, null or number. +fn parse_fields(raw: &[String], typed: &[String]) -> Result, String> { + let mut fields = Map::new(); + let split = |f: &str| { + f.split_once('=') + .map(|(k, v)| (k.to_string(), v.to_string())) + .ok_or_else(|| format!("Expected KEY=VALUE, got '{f}'")) + }; + for f in raw { + let (k, v) = split(f)?; + fields.insert(k, Value::String(v)); + } + for f in typed { + let (k, v) = split(f)?; + let value = match serde_json::from_str::(&v) { + Ok(j @ (Value::Bool(_) | Value::Null | Value::Number(_))) => j, + _ => Value::String(v), + }; + fields.insert(k, value); + } + Ok(fields) +} + +fn read_json(path: &str) -> Result { + let text = if path == "-" { + let mut s = String::new(); + std::io::stdin() + .read_to_string(&mut s) + .map_err(|e| format!("Couldn't read stdin: {e}"))?; + s + } else { + std::fs::read_to_string(path).map_err(|e| format!("Couldn't read {path}: {e}"))? + }; + serde_json::from_str(&text).map_err(|e| format!("{path} isn't JSON: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{body_json, method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn args(path: &str) -> ApiArgs { + ApiArgs { + path: path.into(), + method: None, + raw_fields: vec![], + fields: vec![], + input: None, + } + } + + #[test] + fn typed_fields_become_json() { + let f = parse_fields( + &["n=5".into()], + &["a=true".into(), "b=null".into(), "c=7".into(), "d=x".into()], + ) + .unwrap(); + assert_eq!( + Value::Object(f), + json!({"n": "5", "a": true, "b": null, "c": 7, "d": "x"}) + ); + assert!(parse_fields(&["nokey".into()], &[]).is_err()); + } + + #[tokio::test] + async fn get_puts_fields_in_the_query() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/products/acct")) + .and(query_param("limit", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"items": []}))) + .expect(1) + .mount(&server) + .await; + let client = Client::new(&server.uri(), None, false).unwrap(); + let mut a = args("/products/acct"); + a.method = Some("get".into()); + a.fields = vec!["limit=2".into()]; + run(a, &client).await.unwrap(); + } + + #[tokio::test] + async fn fields_default_to_a_post_body() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v1/products/acct")) + .and(body_json(json!({"product_id": "p", "title": "T"}))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) + .expect(1) + .mount(&server) + .await; + let client = Client::new(&server.uri(), None, false).unwrap(); + let mut a = args("products/acct"); + a.raw_fields = vec!["product_id=p".into(), "title=T".into()]; + run(a, &client).await.unwrap(); + } + + #[tokio::test] + async fn an_error_status_fails_with_the_apis_message() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(404).set_body_json( + json!({"error": {"code": "not_found", "message": "No such product."}}), + )) + .mount(&server) + .await; + let client = Client::new(&server.uri(), None, false).unwrap(); + let err = run(args("products/acct/nope"), &client).await.unwrap_err(); + assert!(err.contains("No such product. (not_found, HTTP 404)")); + } +} diff --git a/src/main.rs b/src/main.rs index 1271952..ee9fa86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod api; +mod api_cmd; mod cache; mod oidc; mod output; @@ -70,6 +71,18 @@ enum Commands { Auth(AuthCommand), /// List, view, create, edit and delete products Product(ProductArgs), + /// Make any request to the source.coop API, signed in as you + Api(ApiCommandArgs), +} + +#[derive(Parser)] +struct ApiCommandArgs { + /// source.coop site URL; the API is served under its `/api/v1` + #[arg(long, env = "SOURCE_API_URL", default_value = defaults::API_URL)] + api_url: String, + + #[command(flatten)] + request: api_cmd::ApiArgs, } /// A command that calls the source.coop API, as whoever ran `login` (see @@ -194,6 +207,17 @@ async fn main() { std::process::exit(1); } } + Commands::Api(args) => { + let token = api_token(verbose).await; + let result = match api::Client::new(&args.api_url, token, verbose) { + Ok(client) => api_cmd::run(args.request, &client).await, + Err(e) => Err(e), + }; + if let Err(e) = result { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } Commands::Product(args) => { let token = api_token(verbose).await; let result = match api::Client::new(&args.api_url, token, verbose) { diff --git a/src/product.rs b/src/product.rs index a7f4da7..15a6833 100644 --- a/src/product.rs +++ b/src/product.rs @@ -1,12 +1,12 @@ //! `source-coop product`: list, view, create, edit and delete products through //! `/api/v1/products`. -use crate::api::{Client, Page}; +use crate::api::{ApiError, Client, Page}; use crate::prompt::Prompter; use clap::{Args, Subcommand}; use reqwest::Method; use serde_json::{json, Map, Value}; -use std::io::Read; +use std::io::{IsTerminal, Read}; #[derive(Subcommand)] pub enum ProductCommand { @@ -187,8 +187,8 @@ pub async fn run( match cmd { ProductCommand::List(args) => list(args, client).await, ProductCommand::View(args) => view(args, client, site).await, - ProductCommand::Create(args) => create(args, client, prompter).await, - ProductCommand::Edit(args) => edit(args, client, prompter).await, + ProductCommand::Create(args) => create(args, client, site, prompter).await, + ProductCommand::Edit(args) => edit(args, client, site, prompter).await, ProductCommand::Delete(args) => delete(args, client, prompter).await, } } @@ -263,6 +263,7 @@ const VISIBILITIES: [&str; 3] = ["public", "unlisted", "restricted"]; async fn create( args: CreateArgs, client: &Client, + site: &str, mut prompter: Option<&mut dyn Prompter>, ) -> Result<(), String> { let mut body = read_fields(args.from_file.as_deref())?; @@ -278,13 +279,86 @@ async fn create( }; body.insert("product_id".into(), json!(product.product_id)); - if let Some(ask) = prompter { + if let Some(ask) = prompter.as_deref_mut() { prompt_new_product(ask, client, &product, &mut body).await?; } let url = client.url(&["products", &product.account_id]); - let created: Value = client.request(Method::POST, url, Some(&body)).await?; - report(&created, args.json, "Created"); + let created = send_until_accepted(client, Method::POST, url, &mut body, prompter).await?; + report(&created, args.json, "Created", site); + Ok(()) +} + +/// The fields a person can be asked for again when the API rejects them. +const REASKABLE: [&str; 5] = [ + "product_id", + "title", + "description", + "visibility", + "data_connection_id", +]; + +/// Send `body`, and when the API rejects some of its fields and someone is +/// there to answer, show why and ask for just those fields again, keeping +/// every other answer. The API stays the only judge of what's acceptable. +async fn send_until_accepted( + client: &Client, + method: Method, + url: url::Url, + body: &mut Map, + mut prompter: Option<&mut dyn Prompter>, +) -> Result { + loop { + match client + .request::(method.clone(), url.clone(), Some(&*body)) + .await + { + Ok(product) => return Ok(product), + Err(e) => match prompter.as_deref_mut() { + Some(ask) if reaskable(&e) => { + eprintln!("{e}"); + reask(ask, &e, body)?; + } + _ => return Err(e.into()), + }, + } + } +} + +/// A 400 naming only fields a person can answer again. +fn reaskable(e: &ApiError) -> bool { + e.status == Some(reqwest::StatusCode::BAD_REQUEST) + && !e.field_errors.is_empty() + && e.field_errors + .keys() + .all(|f| REASKABLE.contains(&f.as_str())) +} + +fn reask( + ask: &mut dyn Prompter, + e: &ApiError, + body: &mut Map, +) -> Result<(), String> { + for field in e.field_errors.keys() { + let current = body + .get(field) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let answer = match field.as_str() { + "product_id" => ask.input("Product ID", ¤t, false)?, + "title" => ask.input("Title", ¤t, false)?, + "description" => ask.long_text("Description", ¤t)?, + "visibility" => { + let options: Vec = VISIBILITIES.iter().map(|v| v.to_string()).collect(); + let default = options.iter().position(|v| *v == current).unwrap_or(0); + options[ask.select("Visibility", &options, default)?].clone() + } + "data_connection_id" => ask.input("Data connection ID", ¤t, false)?, + _ => unreachable!("reaskable() checked every field"), + }; + body.insert(field.clone(), json!(answer)); + } Ok(()) } @@ -303,7 +377,7 @@ async fn prompt_new_product( body.insert("title".into(), json!(title)); } if !body.contains_key("description") { - let description = ask.input("Description", "", true)?; + let description = ask.long_text("Description", "")?; body.insert("description".into(), json!(description)); } @@ -387,7 +461,8 @@ fn title_from_id(id: &str) -> String { async fn edit( args: EditArgs, client: &Client, - prompter: Option<&mut dyn Prompter>, + site: &str, + mut prompter: Option<&mut dyn Prompter>, ) -> Result<(), String> { let mut body = read_fields(args.from_file.as_deref())?; insert_some(&mut body, "title", args.title); @@ -399,7 +474,7 @@ async fn edit( let url = product_url(client, &args.product); if body.is_empty() { - let Some(ask) = prompter else { + let Some(ask) = prompter.as_deref_mut() else { return Err( "Nothing to change: pass --title, --description, --visibility, --disable, --enable or --from-file." .into(), @@ -413,35 +488,41 @@ async fn edit( } } - let product: Value = client.request(Method::PATCH, url, Some(&body)).await?; - report(&product, args.json, "Edited"); + let product = send_until_accepted(client, Method::PATCH, url, &mut body, prompter).await?; + report(&product, args.json, "Edited", site); Ok(()) } -/// Walk through the editable fields with their current values as defaults, -/// keeping only the ones that changed. +/// Ask which fields to change, then for each, starting from its current value; +/// only the ones that changed are kept. fn prompt_edits( ask: &mut dyn Prompter, current: &Value, body: &mut Map, ) -> Result<(), String> { - for (key, label, allow_empty) in [ - ("title", "Title", false), - ("description", "Description", true), - ] { - let was = str_field(current, key); - let now = ask.input(label, was, allow_empty)?; - if now != was { + let fields = ["Title", "Description", "Visibility"].map(String::from); + for picked in ask.multi_select("What do you want to change?", &fields)? { + let (key, now) = match picked { + 0 => ( + "title", + ask.input("Title", str_field(current, "title"), false)?, + ), + 1 => ( + "description", + ask.long_text("Description", str_field(current, "description"))?, + ), + _ => { + let was = str_field(current, "visibility"); + let options: Vec = VISIBILITIES.iter().map(|v| v.to_string()).collect(); + let default = options.iter().position(|v| v == was).unwrap_or(0); + let now = options[ask.select("Visibility", &options, default)?].clone(); + ("visibility", now) + } + }; + if now != str_field(current, key) { body.insert(key.into(), json!(now)); } } - let was = str_field(current, "visibility"); - let options: Vec = VISIBILITIES.iter().map(|v| v.to_string()).collect(); - let default = options.iter().position(|v| v == was).unwrap_or(0); - let now = &options[ask.select("Visibility", &options, default)?]; - if now != was { - body.insert("visibility".into(), json!(now)); - } Ok(()) } @@ -476,7 +557,11 @@ async fn delete( url.query_pairs_mut() .append_pair("preserve_data", &preserve_data.to_string()); let product: Value = client.request(Method::DELETE, url, None::<&()>).await?; - report(&product, args.json, "Deleted"); + if args.json { + print_json(&product); + } else { + eprintln!("Deleted {}", name(&product)); + } Ok(()) } @@ -512,11 +597,15 @@ fn insert_some(body: &mut Map, key: &str, value: Option) } } -fn report(product: &Value, as_json: bool, verb: &str) { +/// The product as JSON, or a line on stderr for the person and its URL on +/// stdout for a script: `url=$(source-coop product create ...)`. +fn report(product: &Value, as_json: bool, verb: &str, site: &str) { if as_json { print_json(product); } else { - eprintln!("{verb} {}", name(product)); + let name = name(product); + eprintln!("{verb} {name}"); + println!("{}/{name}", site.trim_end_matches('/')); } } @@ -551,8 +640,10 @@ fn print_product(p: &Value, site: &str) { println!("\n{}/{name}", site.trim_end_matches('/')); } -/// Rows of `NAME VISIBILITY TITLE`, padded to line up. -fn table_rows(items: &[Value]) -> Vec { +/// On a terminal, `NAME VISIBILITY TITLE` under a header, padded to line +/// up; piped, the same columns tab-separated with no header, for `cut` and +/// `awk`. +fn table_rows(items: &[Value], terminal: bool) -> Vec { let rows: Vec<[String; 3]> = items .iter() .map(|p| { @@ -563,15 +654,20 @@ fn table_rows(items: &[Value]) -> Vec { ] }) .collect(); - let w0 = rows.iter().map(|r| r[0].chars().count()).max().unwrap_or(0); - let w1 = rows.iter().map(|r| r[1].chars().count()).max().unwrap_or(0); - rows.iter() + if !terminal { + return rows.iter().map(|r| r.join("\t")).collect(); + } + let header = ["NAME", "VISIBILITY", "TITLE"].map(String::from); + let all: Vec<&[String; 3]> = std::iter::once(&header).chain(&rows).collect(); + let w0 = all.iter().map(|r| r[0].chars().count()).max().unwrap_or(0); + let w1 = all.iter().map(|r| r[1].chars().count()).max().unwrap_or(0); + all.iter() .map(|[n, v, t]| format!("{n: Value { json!({"account_id": account, "product_id": id, "title": format!("{id} title"), "description": "", "visibility": "public", "disabled": false}) @@ -651,15 +749,20 @@ mod tests { } #[test] - fn table_lines_up() { - let rows = table_rows(&[product("a", "one"), product("longer", "two")]); + fn table_lines_up_on_a_terminal_and_tabs_when_piped() { + let items = [product("a", "one"), product("longer", "two")]; assert_eq!( - rows, + table_rows(&items, true), [ - "a/one public one title", - "longer/two public two title" + "NAME VISIBILITY TITLE", + "a/one public one title", + "longer/two public two title" ] ); + assert_eq!( + table_rows(&items, false), + ["a/one\tpublic\tone title", "longer/two\tpublic\ttwo title"] + ); } #[test] @@ -728,13 +831,15 @@ mod tests { let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); let mut args = create_args(Some("acct/prod")); args.title = Some("T".into()); - create(args, &client, None).await.unwrap(); + create(args, &client, SITE, None).await.unwrap(); } #[tokio::test] async fn create_without_a_terminal_needs_the_product_named() { let client = Client::new("http://127.0.0.1:9", None, false).unwrap(); - let err = create(create_args(None), &client, None).await.unwrap_err(); + let err = create(create_args(None), &client, SITE, None) + .await + .unwrap_err(); assert!(err.contains("ACCOUNT/PRODUCT")); } @@ -762,7 +867,7 @@ mod tests { let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); let mut ask = Script::new([Text("acct/my-data"), Default, Default, Pick(1), Default]); - create(create_args(None), &client, Some(&mut ask)) + create(create_args(None), &client, SITE, Some(&mut ask)) .await .unwrap(); assert_eq!( @@ -770,7 +875,7 @@ mod tests { [ "Product (ACCOUNT/PRODUCT) []", "Title [My Data]", - "Description []", + "Description (long) []", // Someone else's connection isn't offered. "Data connection [Shared (shared)] of Shared (shared) | Mine (mine, read-only)", // Only what the chosen connection allows, starting on public. @@ -803,7 +908,7 @@ mod tests { args.title = Some("Flag wins".into()); args.from_file = Some(f.to_str().unwrap().into()); let mut ask = Script::new([Pick(1)]); - create(args, &client, Some(&mut ask)).await.unwrap(); + create(args, &client, SITE, Some(&mut ask)).await.unwrap(); assert_eq!( ask.asked, ["Visibility [public] of public | unlisted | restricted"] @@ -828,9 +933,14 @@ mod tests { let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); let mut ask = Script::new([Text("T"), Text("D"), Text("typed"), Default]); - create(create_args(Some("acct/prod")), &client, Some(&mut ask)) - .await - .unwrap(); + create( + create_args(Some("acct/prod")), + &client, + SITE, + Some(&mut ask), + ) + .await + .unwrap(); } #[tokio::test] @@ -845,7 +955,7 @@ mod tests { .await; let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); - let err = create(create_args(Some("acct/prod")), &client, None) + let err = create(create_args(Some("acct/prod")), &client, SITE, None) .await .unwrap_err(); assert!(err.contains("data_connection_id: A data connection is required")); @@ -865,8 +975,8 @@ mod tests { let mut args = edit_args(); args.disable = true; - edit(args, &client, None).await.unwrap(); - assert!(edit(edit_args(), &client, None) + edit(args, &client, SITE, None).await.unwrap(); + assert!(edit(edit_args(), &client, SITE, None) .await .unwrap_err() .contains("Nothing to change")); @@ -891,9 +1001,18 @@ mod tests { .await; let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); - let mut ask = Script::new([Default, Text("New"), Pick(1)]); - edit(edit_args(), &client, Some(&mut ask)).await.unwrap(); - assert_eq!(ask.asked[0], "Title [prod title]"); + let mut ask = Script::new([Picks(&[1, 2]), Text("New"), Pick(1)]); + edit(edit_args(), &client, SITE, Some(&mut ask)) + .await + .unwrap(); + assert_eq!( + ask.asked, + [ + "What do you want to change? of Title | Description | Visibility", + "Description (long) []", + "Visibility [public] of public | unlisted | restricted", + ] + ); } #[tokio::test] @@ -911,8 +1030,11 @@ mod tests { .await; let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); - let mut ask = Script::new([Default, Default, Default]); - edit(edit_args(), &client, Some(&mut ask)).await.unwrap(); + // Picking a field and keeping its value changes nothing either. + let mut ask = Script::new([Picks(&[0]), Default]); + edit(edit_args(), &client, SITE, Some(&mut ask)) + .await + .unwrap(); } /// Answer a DELETE of acct/prod, but only with this `preserve_data`. @@ -967,4 +1089,58 @@ mod tests { .unwrap_err(); assert!(err.contains("nothing was deleted")); } + #[tokio::test] + async fn create_asks_again_for_just_the_rejected_fields() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v1/products/acct")) + .and(body_json(json!({"product_id": "Bad--ID", "title": "T", "description": "D", + "data_connection_id": "dc", "visibility": "public"}))) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({"error": { + "code": "invalid", "message": "The request is invalid.", + "field_errors": {"product_id": ["Product ID may not contain consecutive hyphens"]}}}))) + .expect(1) + .mount(&server) + .await; + expect_create( + &server, + json!({"product_id": "good-id", "title": "T", "description": "D", + "data_connection_id": "dc", "visibility": "public"}), + ) + .await; + + let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); + let mut args = create_args(Some("acct/Bad--ID")); + args.title = Some("T".into()); + args.description = Some("D".into()); + args.data_connection_id = Some("dc".into()); + args.visibility = Some("public".into()); + let mut ask = Script::new([Text("good-id")]); + create(args, &client, SITE, Some(&mut ask)).await.unwrap(); + // Only the rejected field is asked for, starting from what was sent. + assert_eq!(ask.asked, ["Product ID [Bad--ID]"]); + } + + #[tokio::test] + async fn a_rejection_naming_other_fields_is_just_an_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({"error": { + "code": "invalid", "message": "The request is invalid.", + "field_errors": {"metadata": ["Not allowed"]}}}))) + .expect(1) + .mount(&server) + .await; + let client = Client::new(&server.uri(), Some("tkn".into()), false).unwrap(); + let mut args = create_args(Some("acct/prod")); + args.title = Some("T".into()); + args.description = Some("D".into()); + args.data_connection_id = Some("dc".into()); + args.visibility = Some("public".into()); + let mut ask = Script::new([]); + let err = create(args, &client, SITE, Some(&mut ask)) + .await + .unwrap_err(); + assert!(err.contains("metadata: Not allowed")); + } } diff --git a/src/prompt.rs b/src/prompt.rs index 08f39bf..15edf2d 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -4,7 +4,7 @@ //! are both terminals and `SOURCE_PROMPT_DISABLED` is unset. Otherwise a //! command sends what it was given, and the API says what's missing. -use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; +use dialoguer::{theme::ColorfulTheme, Confirm, Editor, Input, MultiSelect, Select}; use std::io::IsTerminal; pub trait Prompter { @@ -14,6 +14,11 @@ pub trait Prompter { fn select(&mut self, label: &str, items: &[String], default: usize) -> Result; /// Ask a yes/no question. fn confirm(&mut self, label: &str, default: bool) -> Result; + /// Ask for text that may run to paragraphs, starting from `current`: typed + /// on one line, or written in the person's editor. + fn long_text(&mut self, label: &str, current: &str) -> Result; + /// Ask for any number of `items`; returns their indexes. + fn multi_select(&mut self, label: &str, items: &[String]) -> Result, String>; } /// Prompts on the terminal, written to stderr so stdout stays clean for output. @@ -62,6 +67,39 @@ impl Prompter for Tty { .interact() .map_err(failed) } + + /// gh's way: Enter keeps what's there, `e` opens `$VISUAL` or `$EDITOR` + /// on it, and anything else typed is the new text. + fn long_text(&mut self, label: &str, current: &str) -> Result { + let keep = if current.is_empty() { "skip" } else { "keep" }; + let typed = Input::::with_theme(&self.theme) + .with_prompt(format!( + "{label} [(e) to open your editor, Enter to {keep}]" + )) + .allow_empty(true) + .interact_text() + .map_err(failed)?; + match typed.trim() { + "" => Ok(current.to_string()), + "e" => { + let edited = Editor::new() + .require_save(true) + .edit(current) + .map_err(|e| format!("Couldn't open your editor: {e}"))?; + // Quitting without saving keeps what was there. + Ok(edited.map_or_else(|| current.to_string(), |t| t.trim_end().to_string())) + } + _ => Ok(typed), + } + } + + fn multi_select(&mut self, label: &str, items: &[String]) -> Result, String> { + MultiSelect::with_theme(&self.theme) + .with_prompt(format!("{label} (Space to pick, Enter when done)")) + .items(items) + .interact() + .map_err(failed) + } } /// Answers from a script, in order, for tests. @@ -76,6 +114,7 @@ pub mod scripted { Default, Text(&'static str), Pick(usize), + Picks(&'static [usize]), Yes(bool), } @@ -137,5 +176,22 @@ pub mod scripted { a => panic!("{label}: expected yes/no, scripted {a:?}"), }) } + + fn long_text(&mut self, label: &str, current: &str) -> Result { + self.asked.push(format!("{label} (long) [{current}]")); + Ok(match self.next(label) { + Answer::Default => current.to_string(), + Answer::Text(t) => t.to_string(), + a => panic!("{label}: expected text, scripted {a:?}"), + }) + } + + fn multi_select(&mut self, label: &str, items: &[String]) -> Result, String> { + self.asked.push(format!("{label} of {}", items.join(" | "))); + Ok(match self.next(label) { + Answer::Picks(p) => p.to_vec(), + a => panic!("{label}: expected picks, scripted {a:?}"), + }) + } } }