Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
- Guides for wallets, webhooks, troubleshooting, and getting started, and references for global flags, environment variables, and configuration.
- A command that takes a resource ID as an argument also accepts `--id ID`, so the flag that `quotes create` and `payments send` take works there too. It takes one of the two, and its usage line shows both, as in `voltage quotes get <QUOTE_ID|--id <ID>>`.
- In human output, an accepted `payments receive` or `quotes create` names the command that shows the invoice, address, or quote.
- The wallet picker shows a wallet's currency when its balances report one, and typing a currency filters on it. A wallet with no balance yet shows no currency.

### Changed

Expand Down
4 changes: 2 additions & 2 deletions docs/guides/login-and-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ Only endpoints that filter by environment receive `--env`. Organization-wide com

## Pick the scope interactively

On a terminal, a command that lacks the organization, environment, or wallet it needs asks for it instead of failing. The picker lists each choice as `name UUID`; type to filter, use the arrow keys, and press Enter. Afterwards, the CLI prints the flags that select the same scope, so you can pass them next time or save a profile.
On a terminal, a command that lacks the organization, environment, or wallet it needs asks for it instead of failing. The picker lists each choice as `name UUID`, and each wallet as `name CURRENCY UUID` when all of its balances are in one currency. Type to filter, by name or currency, use the arrow keys, and press Enter. Afterwards, the CLI prints the flags that select the same scope, so you can pass them next time or save a profile.

```sh
voltage wallets list # asks for the organization
voltage payments receive --currency btc --kind bolt11 --amount 1000 --unit sats --execute # asks for the wallet
```

The picker offers every wallet in the environment. For this example, pick a wallet on a BTC line of credit. A wallet on a USD line of credit needs a quote for every payment, so the API rejects the receive with `quote_required` unless you pass `--quote QUOTE_ID` from `voltage quotes create`.
The picker offers every wallet in the environment and shows a wallet's currency when its balances report one. A wallet with no balance yet shows no currency. For this example, pick a BTC wallet. Typing filters on the whole label, so `btc` matches wallets that show `BTC` and any wallet with `btc` in its name. A wallet on a USD line of credit needs a quote for every payment, so the API rejects the receive with `quote_required` unless you pass `--quote QUOTE_ID` from `voltage quotes create`.

An API key cannot list organizations or environments, so with a key the CLI offers only wallets; pass `--org` and `--env` or use a profile. Esc leaves the scope unchanged, and the command then reports what is missing. Ctrl-C exits with code 130. Picking a scope never sends a change: changes still need `--execute`. The CLI never asks when stdin or stderr is not a terminal, when stdout is piped without `--output table` (as in `voltage wallets list | jq`), or with `--no-input`, `--json`, or `--output json|ndjson`. In those cases, the command fails with the same error as before.

Expand Down
50 changes: 48 additions & 2 deletions src/pick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ async fn choose(
terminal.pick(question, choices).await
}

/// The listed resources that carry a UUID `id`, sorted by name.
/// The listed resources that carry a UUID `id`, sorted by name. A wallet also shows its
/// currency when all of its balances report the same one.
fn choices(listed: &Value) -> Vec<Choice> {
let items = listed
.as_array()
Expand All @@ -191,13 +192,30 @@ fn choices(listed: &Value) -> Vec<Choice> {
.filter_map(|item| {
let id = item.get("id")?.as_str()?.parse().ok()?;
let name = item.get("name").and_then(Value::as_str).unwrap_or("-");
Some(Choice::new(id, name))
let choice = Choice::new(id, name);
Some(match currency(item) {
Some(currency) => choice.with_currency(currency),
None => choice,
})
Comment on lines +195 to +199

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With with_currency taking an Option (see terminal.rs), the match goes away:

Suggested change
let choice = Choice::new(id, name);
Some(match currency(item) {
Some(currency) => choice.with_currency(currency),
None => choice,
})
Some(Choice::new(id, name).with_currency(currency(item)))

})
.collect();
choices.sort_by_key(|choice| choice.name().to_lowercase());
choices
}

/// The one currency a wallet's balances report, or `None` when they report none or disagree.
fn currency(item: &Value) -> Option<&str> {
let mut currencies = item
.get("balances")?
.as_array()?
.iter()
.map(|balance| balance.get("currency").and_then(Value::as_str));
let first = currencies.next()??;
currencies
.all(|currency| currency == Some(first))
.then_some(first)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -276,4 +294,32 @@ mod tests {
assert_eq!(choices(&json!({"items": [{"id": ID}]})).len(), 1);
assert!(choices(&json!({"unexpected": true})).is_empty());
}

#[test]
fn wallet_choices_show_a_currency_only_when_their_balances_agree() {
let wallet = |balances: Value| json!([{"id": ID, "name": "Payroll", "balances": balances}]);
let label = |listed: Value| choices(&listed)[0].to_string();
let usd = json!({"currency": "usd", "available": 1});
assert_eq!(
label(wallet(json!([usd, usd]))),
format!("Payroll USD {ID}")
);
assert_eq!(
label(wallet(json!([{"currency": "btc\u{1b}[2J"}]))),
format!("Payroll BTC [2J {ID}")
);
for balances in [
json!([]),
json!([usd, {"currency": "btc"}]),
json!([usd, {"available": 1}]),
json!([{"available": 1}]),
json!("usd"),
] {
assert_eq!(
label(wallet(balances.clone())),
format!("Payroll {ID}"),
"{balances}"
);
}
}
}
13 changes: 12 additions & 1 deletion src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,24 +124,35 @@ impl Drop for PickerScreen {
pub struct Choice {
id: Uuid,
name: String,
currency: Option<String>,
}

impl Choice {
pub fn new(id: Uuid, name: &str) -> Self {
Self {
id,
name: scrub_line(name),
currency: None,
}
}

/// Show a wallet's currency between its name and ID, where typing can filter on it.
pub fn with_currency(mut self, currency: &str) -> Self {
self.currency = Some(scrub_line(&currency.to_uppercase()));
Comment on lines +140 to +141

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking the Option here lets choices chain it:

Suggested change
pub fn with_currency(mut self, currency: &str) -> Self {
self.currency = Some(scrub_line(&currency.to_uppercase()));
pub fn with_currency(mut self, currency: Option<&str>) -> Self {
self.currency = currency.map(|currency| scrub_line(&currency.to_uppercase()));

self
}

pub fn name(&self) -> &str {
&self.name
}
}

impl Display for Choice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.name, self.id)
match &self.currency {
Some(currency) => write!(f, "{} {currency} {}", self.name, self.id),
None => write!(f, "{} {}", self.name, self.id),
}
}
}

Expand Down
14 changes: 10 additions & 4 deletions tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4048,8 +4048,8 @@ async fn a_terminal_picks_a_missing_wallet_and_scripts_are_never_asked() {
.and(path(wallets_path(ORG)))
.and(query_param("environment_id", ENV))
.respond_with(ok(json!([
{"id": RESOURCE, "name": "ops"},
{"id": WALLET, "name": "Payroll"}
{"id": RESOURCE, "name": "ops", "balances": [{"currency": "usd"}]},
{"id": WALLET, "name": "Payroll", "balances": [{"currency": "btc"}]}
])))
.mount(&server)
.await;
Expand Down Expand Up @@ -4077,9 +4077,15 @@ async fn a_terminal_picks_a_missing_wallet_and_scripts_are_never_asked() {
.count()
};

// Typing filters the list, and Enter picks; the change is still only described.
let picked = on_a_terminal(dir.path(), &server, &send, &[("Wallet:", b"Pay\r")]).await;
// Each wallet shows its currency, typing filters on it, and Enter picks; the change is
// still only described.
let picked = on_a_terminal(dir.path(), &server, &send, &[("Wallet:", b"btc\r")]).await;
assert_eq!(picked.code, Some(6), "{}", picked.shown);
assert!(
picked.shown.contains(&format!("ops USD {RESOURCE}")),
"{}",
picked.shown
);
assert!(
picked.stdout.contains("body.wallet_id"),
"{}",
Expand Down
Loading