From 2ac8b2ca0ebf8cf7d35b94f477c32478ea16f7c0 Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:01:29 -0500 Subject: [PATCH] feat(cli): show friendly amounts and relative times, and color tables Human output shows amounts in their friendliest exact unit with grouped digits (150,000 sats, $1,234.56), table timestamps relative to now (3 minutes ago), and single-resource timestamps with the relative time beside the UTC time. With color on, table headers and field names are bold and known statuses and outcomes are colored. Styles are added after comfy-table lays out plain text and stdout strips them when color is off, so columns never move and piped tables keep their layout. JSON is unchanged. --- CHANGELOG.md | 1 + Cargo.lock | 1 + Cargo.toml | 3 + docs/reference/output.md | 5 +- src/output.rs | 35 ++- src/output/human.rs | 522 +++++++++++++++++++++++++++++++++++---- src/startup.rs | 1 + src/style.rs | 5 + tests/cli_integration.rs | 35 ++- 9 files changed, 542 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5aeb42..4041095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - An animated spinner on interactive stderr, with the time waited after two seconds; `VOLTAGE_SPINNER=0` keeps it still. A payment wait on a terminal reads as a timeline: one line per status change and a closing `✓` or `✗` line. - `voltage login` on a terminal shows the verification URL as a clickable link, the code in bold, a countdown to the code's expiry, and a closing `✓ Logged in as EMAIL` line. - A change described without `--execute` starts, on a terminal, with what it would do and the values it would use (`! Not sent: this would send a payment`) and ends with how to send it, instead of an `error:` line. Exit code 6 is unchanged. +- Human output shows amounts with grouped digits, every BTC amount in sats (`150,000 sats`, `1.5 sats`) and cents as dollars (`$1,234.56`), and table timestamps relative to now (`3 minutes ago`); single resources show the UTC time with the relative time beside it. With color on, headers and field names are bold and known statuses are colored. JSON is unchanged. ## 0.2.0 - 2026-10-22 diff --git a/Cargo.lock b/Cargo.lock index 33d1721..7ea7934 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2565,6 +2565,7 @@ dependencies = [ "tempfile", "tokio", "toml", + "unicode-width", "url", "uuid", "webbrowser", diff --git a/Cargo.toml b/Cargo.toml index 8460796..cba699b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,9 @@ keyring = { version = "=4.2.0", default-features = false, features = ["v1"] } # Human tables: display-width layout and truncation. Default features stay off for security: # without `tty` and `custom_styling`, cells cannot carry escape sequences past scrubbing. comfy-table = { version = "=8.0.1", default-features = false } +# Display widths, to find a table column after layout and color it; comfy-table already +# depends on it, so it adds no crate. +unicode-width = "=0.2.2" # Reads the terminal width on every platform. Without its `windows` feature, crossterm # refuses to compile on Windows. crossterm = { version = "=0.29.0", default-features = false, features = ["windows"] } diff --git a/docs/reference/output.md b/docs/reference/output.md index 3487cf8..4bc3954 100644 --- a/docs/reference/output.md +++ b/docs/reference/output.md @@ -5,9 +5,10 @@ On a terminal, results are readable tables; when piped, they are JSON. `--json` ## Tables - Tables show up to seven of the fields a list's items share (ID, name, status, amount, and similar) with aligned columns, a result count, and the next cursor. -- Amounts are shown in their base units (`msats` or `cents`). +- Amounts are shown with grouped digits: every BTC amount in sats, with up to three decimals for msats (`150,000 sats`, `1.5 sats`), so a column never mixes units, and cents as dollars (`$1,234.56`). JSON keeps the API's integer and base unit. - A table wider than the terminal (its width, or `COLUMNS`) hides trailing columns and then shortens text cells with `…`, naming what it hid. IDs, timestamps, and amounts are never shortened. -- Timestamps are shown in UTC to the second, such as `2024-02-27 01:58:51 UTC`; JSON keeps the exact value. +- Table timestamps are relative to now, such as `3 minutes ago` or `in 2 days`; anything within a minute either way reads `just now`. A single resource shows the UTC time to the second with the relative time beside it, such as `2024-02-27 01:58:51 UTC (7 months ago)`. JSON keeps the exact value. +- With color on, table headers and field names are bold, a known status is colored by meaning (`completed` green, `failed` red, `sending` yellow), and so is the outcome line (`accepted`, `dry_run`). Color is added after layout, so it never moves a column; an unknown status stays plain. - A table starts with the outcome only when it adds something, such as `accepted` or `dry_run`, or when there is no data to show. - A single resource is shown as aligned `field value` lines, with nested fields joined by dots. `voltage context` shows each setting as `name value (source)`. diff --git a/src/output.rs b/src/output.rs index 28f3917..673e818 100644 --- a/src/output.rs +++ b/src/output.rs @@ -187,6 +187,7 @@ pub struct Output { format: OutputFormat, show_secrets: bool, file: Option, + color: ColorChoice, } /// Reserve `--output-file`. It is reserved before any request, so a refusal sent nothing; the @@ -212,12 +213,14 @@ impl Output { format: OutputFormat, show_secrets: bool, output_file: Option<&Path>, + color: ColorChoice, ) -> Result { let file = output_file.map(reserve_output_file).transpose()?; Ok(Self { format, show_secrets, file, + color, }) } @@ -247,24 +250,34 @@ impl Output { if !self.show_secrets { redact(&mut envelope.data, secrets); } - let mut bytes = match self.format { - OutputFormat::Table => { - let mut buf = Vec::new(); - human::render(&mut buf, &envelope, terminal::width())?; - buf - } - OutputFormat::Json | OutputFormat::Ndjson => serde_json::to_vec(&envelope)?, - }; - if self.format != OutputFormat::Table { - bytes.push(b'\n'); + if self.format == OutputFormat::Table { + // Styled as for a terminal; the stream strips styles when color is off there. + let view = human::View { + width: terminal::width(), + color: true, + ..human::View::plain() + }; + let mut buf = Vec::new(); + human::render(&mut buf, &envelope, view)?; + return write_stdout_styled(&buf, self.color); } + let mut bytes = serde_json::to_vec(&envelope)?; + bytes.push(b'\n'); write_stdout(&bytes) } } /// Write rendered output; a closed stdout ends the pipeline successfully. pub fn write_stdout(bytes: &[u8]) -> Result<()> { - let mut stdout = std::io::stdout().lock(); + finish_stdout(std::io::stdout().lock(), bytes) +} + +/// `write_stdout` for human output, keeping its styles only when `color` allows them there. +fn write_stdout_styled(bytes: &[u8], color: ColorChoice) -> Result<()> { + finish_stdout(style::stdout(color), bytes) +} + +fn finish_stdout(mut stdout: impl Write, bytes: &[u8]) -> Result<()> { stdout .write_all(bytes) .and_then(|()| stdout.flush()) diff --git a/src/output/human.rs b/src/output/human.rs index b5a2bcb..382d712 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -8,10 +8,18 @@ //! truncates table cells by display width, so wide glyphs line up in tables. use super::{Envelope, Layout, Outcome}; -use crate::{Result, terminal::scrub_line}; +use crate::{ + Result, + style::{Line as Styled, Role}, + terminal::scrub_line, +}; use comfy_table::{ColumnConstraint, ContentArrangement, Row, Table, TableStyle, Width}; use serde_json::{Map, Value}; -use std::io::Write; +use std::{ + io::Write, + time::{SystemTime, UNIX_EPOCH}, +}; +use unicode_width::UnicodeWidthChar; /// Preferred list columns, in display order. const COLUMNS: [&str; 13] = [ @@ -36,7 +44,36 @@ const MIN_CELL: u16 = 10; const GAP: &str = " "; const EMPTY_CELL: &str = "-"; -pub fn render(out: &mut impl Write, envelope: &Envelope, width: Option) -> Result<()> { +/// What a rendering may assume beyond the data: the terminal's width, the current time for +/// relative timestamps, and whether to paint headers, field names, and statuses. Styles +/// are added after layout, so they never change a width. +#[derive(Clone, Copy, Debug)] +pub struct View { + pub width: Option, + /// Seconds since the Unix epoch. + pub now: i64, + pub color: bool, +} + +impl View { + /// No width limit and no styles, at the current time. + pub fn plain() -> Self { + Self { + width: None, + now: unix_seconds(SystemTime::now()), + color: false, + } + } +} + +/// Seconds since the epoch; a clock set before 1970 reads as the epoch itself. +fn unix_seconds(time: SystemTime) -> i64 { + time.duration_since(UNIX_EPOCH).map_or(0, |since| { + i64::try_from(since.as_secs()).unwrap_or(i64::MAX) + }) +} + +pub fn render(out: &mut impl Write, envelope: &Envelope, view: View) -> Result<()> { let data = &envelope.data; // A plain success with data says nothing the data does not; other outcomes, such as // `accepted` or `dry_run`, and results without a body still need the line. @@ -46,27 +83,56 @@ pub fn render(out: &mut impl Write, envelope: &Envelope, width: Option) - .resource_id .map(|id| format!(" {id}")) .unwrap_or_default(); - writeln!(out, "{}{resource}", envelope.outcome.as_str())?; + let outcome = envelope.outcome.as_str(); + let outcome = match outcome_role(envelope.outcome).filter(|_| view.color) { + Some(role) => Styled::default().paint(role, outcome).as_str().to_owned(), + None => outcome.to_owned(), + }; + writeln!(out, "{outcome}{resource}")?; } if let Some(items) = list_items(data) { - table(out, items, width)?; + table(out, items, view)?; summary(out, data, items.len())?; return Ok(()); } let local = envelope.layout == Layout::Settings; match data { Value::Null => {} - Value::Object(map) => fields(out, map, local)?, - other => writeln!(out, "{}", cell(other))?, + Value::Object(map) => fields(out, map, local, view)?, + other => writeln!(out, "{}", cell(other, view, Place::Field))?, } Ok(()) } /// An error's detail as aligned `field value` lines, or one line when it is not an object. pub fn detail(out: &mut impl Write, detail: &Value) -> Result<()> { + let view = View::plain(); match detail { - Value::Object(map) => fields(out, map, false), - other => Ok(writeln!(out, "{}", cell(other))?), + Value::Object(map) => fields(out, map, false, view), + other => Ok(writeln!(out, "{}", cell(other, view, Place::Field))?), + } +} + +/// An outcome worth a color: what finished, what is still in flight, and what was not sent. +fn outcome_role(outcome: Outcome) -> Option { + match outcome { + Outcome::Completed | Outcome::Ready => Some(Role::Success), + Outcome::Accepted => Some(Role::Pending), + Outcome::DryRun => Some(Role::Caution), + Outcome::Succeeded | Outcome::Retrieved | Outcome::Event => None, + } +} + +/// A status cell's color, for the words whose meaning is clear across resources. Any +/// other status, including one the API adds later, stays plain. +fn status_role(status: &str) -> Option { + match status { + "completed" | "succeeded" | "ready" | "active" | "paid" => Some(Role::Success), + "failed" | "expired" | "error" | "rejected" => Some(Role::Failure), + "sending" | "receiving" | "generating" | "approved" | "pending" | "processing" => { + Some(Role::Pending) + } + _ => None, } } @@ -119,27 +185,33 @@ fn truncatable(column: &str) -> bool { column != "id" && column != "amount" && !column.ends_with("_id") && !column.ends_with("_at") } -fn table(out: &mut impl Write, items: &[Value], width: Option) -> Result<()> { +fn table(out: &mut impl Write, items: &[Value], view: View) -> Result<()> { + let width = view.width; let columns = columns(items); // Bare values, such as a list of IDs, have no column to judge, so none is shortened. if columns.is_empty() { for item in items { - writeln!(out, "{}", cell(item))?; + writeln!(out, "{}", cell(item, view, Place::Table))?; } return Ok(()); } // The column cap applies before width, and the notice names what either one hid. let capped = &columns[..columns.len().min(MAX_COLUMNS)]; + let labels: Vec = capped + .iter() + .map(|column| scrub_line(&column.replace('_', " ").to_uppercase())) + .collect(); let mut table = Table::new(); table .load_style(TableStyle::new()) .set_truncation_indicator("…") - .set_header(row(capped - .iter() - .map(|column| scrub_line(&column.replace('_', " ").to_uppercase())))); + .set_header(row(labels.iter().cloned())); for item in items { table.add_row(row(capped.iter().map(|column| { - item.get(column).map_or_else(|| EMPTY_CELL.into(), cell) + item.get(column).map_or_else( + || EMPTY_CELL.into(), + |value| cell(value, view, Place::Table), + ) }))); } let widths = table.column_max_content_widths(); @@ -181,8 +253,16 @@ fn table(out: &mut impl Write, items: &[Value], width: Option) -> Result< ColumnConstraint::Absolute(Width::Fixed(widths[index] + gap)) }); } - for line in table.lines() { - writeln!(out, "{}", line.trim_end())?; + let mut lines: Vec = table + .lines() + .map(|line| line.trim_end().to_owned()) + .collect(); + if view.color { + let status = capped[..shown].iter().position(|column| column == "status"); + paint_table(&mut lines, &labels[..shown], status); + } + for line in lines { + writeln!(out, "{line}")?; } if shown < columns.len() { // Without preferred fields, column names are the API's own keys. @@ -207,6 +287,64 @@ fn row(cells: impl Iterator) -> Row { row } +/// Bold the header and color each known status word. The header labels locate the status +/// column by display width; when a label before it was shortened, nothing is colored. +fn paint_table(lines: &mut [String], labels: &[String], status: Option) { + let Some((header, rows)) = lines.split_first_mut() else { + return; + }; + let start = status.and_then(|status| column_start(header, &labels[..=status])); + *header = Styled::default() + .styled(Role::Emphasis, header) + .as_str() + .to_owned(); + let Some(start) = start else { + return; + }; + for line in rows { + let Some(at) = char_at_column(line, start) else { + continue; + }; + let word: String = line[at..].chars().take_while(|c| *c != ' ').collect(); + if let Some(role) = status_role(&word) { + let rest = &line[at + word.len()..]; + *line = format!( + "{}{}{rest}", + &line[..at], + Styled::default().styled(role, &word).as_str() + ); + } + } +} + +/// The display column where the last of `labels` starts in `header`, found by matching each +/// label in turn; `None` when one does not appear whole. +fn column_start(header: &str, labels: &[String]) -> Option { + let mut from = 0; + let mut start = 0; + for label in labels { + start = from + header[from..].find(label.as_str())?; + from = start + label.len(); + } + Some(display_width(&header[..start])) +} + +/// The byte index where display column `column` starts in `line`, if a character starts there. +fn char_at_column(line: &str, column: usize) -> Option { + let mut width = 0; + for (index, c) in line.char_indices() { + if width == column { + return Some(index); + } + width += c.width().unwrap_or(0); + } + None +} + +fn display_width(text: &str) -> usize { + text.chars().map(|c| c.width().unwrap_or(0)).sum() +} + /// How many leading columns fit `limit` at their narrowest, with a gap between each; the /// first is always shown. fn fitting(narrowest: &[u16], limit: usize) -> usize { @@ -221,20 +359,39 @@ fn fitting(narrowest: &[u16], limit: usize) -> usize { .max(1) } -/// One value on one line: strings as themselves, amounts with their base unit, and other -/// structures as compact JSON. -fn cell(value: &Value) -> String { +/// Where a value is shown: a table cell has room for a relative time, and a field line +/// for the exact time beside it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Place { + Table, + Field, +} + +/// One value on one line: strings as themselves, timestamps and amounts in readable form, +/// and other structures as compact JSON. +fn cell(value: &Value, view: View, place: Place) -> String { match value { - Value::String(text) => timestamp(text).unwrap_or_else(|| scrub_line(text)), + Value::String(text) => when(text, view.now, place).unwrap_or_else(|| scrub_line(text)), Value::Null => EMPTY_CELL.into(), other => amount(other).unwrap_or_else(|| scrub_line(&other.to_string())), } } +/// An RFC 3339 UTC timestamp as a table's `3 minutes ago`, or a field's +/// `2024-02-27 01:58:51 UTC (7 months ago)`. +fn when(text: &str, now: i64, place: Place) -> Option { + let (shown, seconds) = timestamp(text)?; + let relative = relative(seconds, now); + Some(match place { + Place::Table => relative, + Place::Field => format!("{shown} ({relative})"), + }) +} + /// An RFC 3339 UTC timestamp, `2024-02-27T01:58:51.693810000Z`, shown to the second as -/// `2024-02-27 01:58:51 UTC`. Anything else, including other offsets, is left as sent; -/// JSON output keeps the exact value. -fn timestamp(text: &str) -> Option { +/// `2024-02-27 01:58:51 UTC`, with its seconds since the epoch. Anything else, including +/// other offsets, is left as sent; JSON output keeps the exact value. +fn timestamp(text: &str) -> Option<(String, i64)> { let (whole, rest) = text.split_at_checked(19)?; let fraction = rest.strip_suffix('Z')?; let shaped = whole.bytes().enumerate().all(|(index, byte)| match index { @@ -246,12 +403,52 @@ fn timestamp(text: &str) -> Option { || fraction .strip_prefix('.') .is_some_and(|f| !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()))); - shaped.then(|| format!("{} {} UTC", &whole[..10], &whole[11..19])) + if !shaped { + return None; + } + let number = |range: std::ops::Range| whole[range].parse::().ok(); + let days = days_from_civil(number(0..4)?, number(5..7)?, number(8..10)?); + let seconds = days * 86_400 + number(11..13)? * 3_600 + number(14..16)? * 60 + number(17..19)?; + Some((format!("{} {} UTC", &whole[..10], &whole[11..19]), seconds)) +} + +/// Days from 1970-01-01 to a proleptic Gregorian date (Howard Hinnant's algorithm). +fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { + let year = if month <= 2 { year - 1 } else { year }; + let era = year.div_euclid(400); + let year_of_era = year - era * 400; + let month_index = (month + 9) % 12; + let day_of_year = (153 * month_index + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - 719_468 +} + +/// `then` against `now` in the largest whole unit: `3 minutes ago`, `in 2 days`. +fn relative(then: i64, now: i64) -> String { + let delta = now.saturating_sub(then); + let seconds = delta.unsigned_abs(); + // Within a minute either way, so a clock a little behind the server's reads the same. + if seconds < 60 { + return "just now".into(); + } + let (count, unit) = match seconds { + 0..3_600 => (seconds / 60, "minute"), + 3_600..86_400 => (seconds / 3_600, "hour"), + 86_400..2_592_000 => (seconds / 86_400, "day"), + 2_592_000..31_536_000 => (seconds / 2_592_000, "month"), + _ => (seconds / 31_536_000, "year"), + }; + let plural = if count == 1 { "" } else { "s" }; + if delta >= 0 { + format!("{count} {unit}{plural} ago") + } else { + format!("in {count} {unit}{plural}") + } } /// An `Amount` as the API sends it: `amount` and `currency`, with an optional `unit` and /// `negative` sign. Without a `unit`, BTC amounts are msats and USD amounts are cents, so -/// `{"amount": 1000, "currency": "btc"}` is 1000 msats. +/// `{"amount": 1000, "currency": "btc"}` is 1000 msats, shown as `1 sat`. fn amount(value: &Value) -> Option { let map = value.as_object()?; let amount = map.get("amount").filter(|amount| amount.is_number())?; @@ -274,7 +471,56 @@ fn amount(value: &Value) -> Option { Some(negative) if negative.as_bool()? => "-", Some(_) | None => "", }; - Some(scrub_line(&format!("{sign}{amount} {unit}"))) + Some(scrub_line(&format!( + "{sign}{}", + friendly(&amount.to_string(), unit) + ))) +} + +/// The readable form of a whole number of `unit`: every BTC amount in sats, with up to three +/// decimals for msats, so one column never mixes units; cents as dollars; anything else +/// with its digits grouped. A number that is not a plain integer is left as sent. +fn friendly(amount: &str, unit: &str) -> String { + if amount.is_empty() || !amount.bytes().all(|byte| byte.is_ascii_digit()) { + return format!("{amount} {unit}"); + } + match unit { + "msats" => { + let padded = format!("{amount:0>4}"); + let (sats, msats) = padded.split_at(padded.len() - 3); + match msats.trim_end_matches('0') { + "" => counted(sats, "sat"), + fraction => format!("{}.{fraction} sats", grouped(sats)), + } + } + "sats" => counted(amount, "sat"), + "cents" => { + let padded = format!("{amount:0>3}"); + let (dollars, cents) = padded.split_at(padded.len() - 2); + format!("${}.{cents}", grouped(dollars)) + } + unit => format!("{} {unit}", grouped(amount)), + } +} + +/// `1 sat`, `1,000 sats`. +fn counted(amount: &str, unit: &str) -> String { + let plural = if amount == "1" { "" } else { "s" }; + format!("{} {unit}{plural}", grouped(amount)) +} + +/// Digits grouped in threes: `1234567` reads `1,234,567`. +fn grouped(digits: &str) -> String { + let digits = digits.trim_start_matches('0'); + let digits = if digits.is_empty() { "0" } else { digits }; + let mut grouped = String::with_capacity(digits.len() + digits.len() / 3); + for (index, digit) in digits.chars().enumerate() { + if index > 0 && (digits.len() - index) % 3 == 0 { + grouped.push(','); + } + grouped.push(digit); + } + grouped } /// One field: its value's lines, and where a local setting's value came from. @@ -287,9 +533,9 @@ struct Line { /// Nested objects flatten to dotted field names; amounts stay whole. A list of strings, such /// as a described change's notes, puts each item on its own line under the first. In a local result, a /// `{value, source}` setting reads `name value (source)`. -fn fields(out: &mut impl Write, map: &Map, local: bool) -> Result<()> { +fn fields(out: &mut impl Write, map: &Map, local: bool, view: View) -> Result<()> { let mut lines = Vec::new(); - flatten(String::new(), map, local, &mut lines); + flatten(String::new(), map, local, view, &mut lines); let width = lines .iter() .map(|line| line.key.chars().count()) @@ -310,13 +556,28 @@ fn fields(out: &mut impl Write, map: &Map, local: bool) -> Result } else { ("", "") }; - writeln!(out, "{key:, local: bool, lines: &mut Vec) { +fn flatten( + prefix: String, + map: &Map, + local: bool, + view: View, + lines: &mut Vec, +) { for (key, value) in map { let key = scrub_line(&if prefix.is_empty() { key.clone() @@ -326,18 +587,18 @@ fn flatten(prefix: String, map: &Map, local: bool, lines: &mut Ve if local && let Some((value, source)) = value.as_object().and_then(setting) { lines.push(Line { key, - values: value_lines(value), + values: value_lines(value, view), source: Some(scrub_line(source)), }); continue; } match value { Value::Object(nested) if !nested.is_empty() && amount(value).is_none() => { - flatten(key, nested, local, lines); + flatten(key, nested, local, view, lines); } other => lines.push(Line { key, - values: value_lines(other), + values: value_lines(other, view), source: None, }), } @@ -354,29 +615,41 @@ fn setting(map: &Map) -> Option<(&Value, &str)> { /// A field's value as display lines: one per item of a list of strings, `-` for an empty /// list, and otherwise the single `cell`. -fn value_lines(value: &Value) -> Vec { +fn value_lines(value: &Value, view: View) -> Vec { let strings = value .as_array() .and_then(|items| items.iter().map(Value::as_str).collect::>>()); match strings { Some(items) if items.is_empty() => vec![EMPTY_CELL.into()], Some(items) => items.into_iter().map(scrub_line).collect(), - None => vec![cell(value)], + None => vec![cell(value, view, Place::Field)], } } #[cfg(test)] mod tests { use super::*; - use crate::output::Outcome; + use crate::{output::Outcome, style::paint}; use serde_json::json; + use std::time::Duration; const ID: &str = "11111111-1111-4111-8111-111111111111"; + /// 2024-03-01 00:00:00 UTC, so relative times in expectations stay fixed. + const NOW: i64 = 1_709_251_200; + + fn view(width: Option) -> View { + View { + width, + now: NOW, + color: false, + } + } + fn rendered(data: Value, width: Option) -> String { let envelope = Envelope::new(Some(200), data, None, Outcome::Retrieved); let mut out = Vec::new(); - render(&mut out, &envelope, width).unwrap(); + render(&mut out, &envelope, view(width)).unwrap(); String::from_utf8(out).unwrap() } @@ -392,8 +665,8 @@ mod tests { assert_eq!( text, "ID NAME STATUS AMOUNT\n\ - a - completed 1000 msats\n\ - bb treasury - 250 cents\n\ + a - completed 1 sat\n\ + bb treasury - $2.50\n\ 2 results.\n\ Next cursor: c1\n" ); @@ -413,13 +686,153 @@ mod tests { // An object with fields beyond an amount's is not an amount. assert_eq!( text, - "held 5 msats\n\ + "held 0.005 sats\n\ other.amount 1\n\ other.currency btc\n\ other.note x\n\ - received 1000 msats\n\ - spent -250 cents\n" + received 1 sat\n\ + spent -$2.50\n" + ); + } + + #[test] + fn amounts_read_in_their_friendliest_exact_unit_with_grouped_digits() { + for (amount, unit, shown) in [ + // Every BTC amount reads in sats, so msats become up to three decimals. + ("0", "msats", "0 sats"), + ("1", "msats", "0.001 sats"), + ("999", "msats", "0.999 sats"), + ("1000", "msats", "1 sat"), + ("1500", "msats", "1.5 sats"), + ("1234567", "msats", "1,234.567 sats"), + ("150000", "msats", "150 sats"), + ("150000000", "msats", "150,000 sats"), + ("2100000000000000000", "msats", "2,100,000,000,000,000 sats"), + ("1000", "sats", "1,000 sats"), + ("0", "cents", "$0.00"), + ("5", "cents", "$0.05"), + ("123456", "cents", "$1,234.56"), + ("1000", "usd", "1,000 usd"), + // A number that is not a plain integer is left as sent. + ("1.5", "msats", "1.5 msats"), + ("1e3", "cents", "1e3 cents"), + ] { + assert_eq!(friendly(amount, unit), shown, "{amount} {unit}"); + } + assert_eq!(grouped("000123"), "123"); + assert_eq!(grouped("0"), "0"); + } + + #[test] + fn times_read_relative_to_now_in_the_largest_whole_unit() { + for (offset, shown) in [ + (0, "just now"), + (59, "just now"), + (60, "1 minute ago"), + (7_199, "1 hour ago"), + (7_200, "2 hours ago"), + // Each unit starts exactly at its boundary. + (3_600, "1 hour ago"), + (86_400, "1 day ago"), + (2_592_000, "1 month ago"), + (31_536_000, "1 year ago"), + (86_400 * 3, "3 days ago"), + (86_400 * 45, "1 month ago"), + (86_400 * 800, "2 years ago"), + (-30, "just now"), + (-59, "just now"), + (-86_400 * 2, "in 2 days"), + ] { + assert_eq!(relative(NOW - offset, NOW), shown, "{offset}"); + } + let created = "2024-02-27T01:58:51Z"; + assert_eq!(when(created, NOW, Place::Table).unwrap(), "2 days ago"); + assert_eq!( + when(created, NOW, Place::Field).unwrap(), + "2024-02-27 01:58:51 UTC (2 days ago)" + ); + assert_eq!(when("yesterday", NOW, Place::Field), None); + } + + fn styled(envelope: &Envelope, width: Option) -> String { + let mut out = Vec::new(); + let view = View { + color: true, + ..view(width) + }; + render(&mut out, envelope, view).unwrap(); + String::from_utf8(out).unwrap() + } + + fn strip(text: &str) -> String { + anstream::adapter::strip_str(text).to_string() + } + + #[test] + fn color_marks_headers_statuses_and_outcomes_without_moving_anything() { + let items = json!([ + {"id": "日本", "status": "completed", "name": "x"}, + {"id": "b", "status": "failed", "name": "y"}, + {"id": "c", "status": "brand-new", "name": "z"} + ]); + let envelope = Envelope::new(Some(202), items.clone(), None, Outcome::Accepted); + let text = styled(&envelope, None); + let mut plain = Vec::new(); + render(&mut plain, &envelope, view(None)).unwrap(); + assert_eq!(strip(&text), String::from_utf8(plain).unwrap()); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines[0], paint(Role::Pending, "accepted")); + assert!(lines[1].starts_with("\u{1b}[1m"), "{:?}", lines[1]); + // A wide glyph before the status column does not shift where the color goes. + assert!(lines[2].contains(&paint(Role::Success, "completed"))); + assert!(lines[3].contains(&paint(Role::Failure, "failed"))); + // A status the palette does not know stays plain. + assert_eq!(lines[4], "c z brand-new"); + } + + #[test] + fn painting_an_empty_or_short_table_changes_nothing_it_cannot_place() { + // No lines: nothing to paint. + let mut empty: Vec = Vec::new(); + paint_table(&mut empty, &[], Some(0)); + assert!(empty.is_empty()); + // A row that ends before the status column is left as it is. + let mut lines = vec!["ID STATUS".to_owned(), "a".to_owned()]; + paint_table(&mut lines, &["ID".to_owned(), "STATUS".to_owned()], Some(1)); + assert_eq!(lines[1], "a"); + } + + #[test] + fn a_clock_before_1970_reads_as_the_epoch() { + assert_eq!(unix_seconds(UNIX_EPOCH - Duration::from_secs(5)), 0); + assert_eq!(unix_seconds(UNIX_EPOCH + Duration::from_secs(5)), 5); + } + + #[test] + fn a_shortened_label_before_the_status_leaves_statuses_plain() { + let header = "ORGANIZAT… STATUS".to_owned(); + let labels = ["ORGANIZATION NAME".to_owned(), "STATUS".to_owned()]; + let mut lines = vec![header, "acme corp failed".to_owned()]; + paint_table(&mut lines, &labels, Some(1)); + assert!(lines[0].starts_with("\u{1b}[1m")); + assert_eq!(lines[1], "acme corp failed"); + assert_eq!(char_at_column("ab", 5), None); + } + + #[test] + fn color_bolds_field_names_only() { + let envelope = Envelope::new( + Some(200), + json!({"id": "w", "tags": ["a", "b"]}), + None, + Outcome::Retrieved, ); + let text = styled(&envelope, None); + assert_eq!(strip(&text), "id w\ntags a\n b\n"); + assert!(text.starts_with(&format!( + "{} w\n", + Styled::default().styled(Role::Emphasis, "id").as_str() + ))); } #[test] @@ -438,7 +851,8 @@ mod tests { ); let wide = rendered(json!([item]), None); assert!(wide.contains("a very long wallet name indeed")); - assert!(wide.contains("2026-09-29 10:00:00 UTC")); + // A table shows a timestamp relative to now; JSON keeps the exact value. + assert!(wide.contains("in 2 years"), "{wide}"); let tiny = rendered(json!([item]), Some(10)); assert!(tiny.lines().nth(1).unwrap().starts_with(ID)); } @@ -511,7 +925,7 @@ mod tests { // Each list item gets its own line; an empty list reads `-`, not `[]`. assert_eq!( text, - "balance.available 5 cents\n\ + "balance.available $0.05\n\ balance.held -\n\ empty {}\n\ id w\n\ @@ -534,7 +948,7 @@ mod tests { })) .unwrap(); let mut out = Vec::new(); - render(&mut out, &envelope, None).unwrap(); + render(&mut out, &envelope, view(None)).unwrap(); assert_eq!( String::from_utf8(out).unwrap(), format!( @@ -557,7 +971,7 @@ mod tests { ); let price = Envelope::local(json!({"x": {"value": 1, "source": "s"}})).unwrap(); let mut out = Vec::new(); - render(&mut out, &price, None).unwrap(); + render(&mut out, &price, view(None)).unwrap(); assert_eq!( String::from_utf8(out).unwrap(), "x.source s\nx.value 1\n" @@ -569,7 +983,7 @@ mod tests { let render_with = |outcome, data: Value| { let envelope = Envelope::new(Some(202), data, Some(ID.parse().unwrap()), outcome); let mut out = Vec::new(); - render(&mut out, &envelope, None).unwrap(); + render(&mut out, &envelope, view(None)).unwrap(); String::from_utf8(out).unwrap() }; assert_eq!( @@ -589,12 +1003,16 @@ mod tests { #[test] fn utc_timestamps_are_shown_to_the_second_and_anything_else_is_left_alone() { assert_eq!( - timestamp("2024-02-27T01:58:51.693810000Z").as_deref(), - Some("2024-02-27 01:58:51 UTC") + timestamp("2024-02-27T01:58:51.693810000Z"), + Some(("2024-02-27 01:58:51 UTC".into(), 1_708_999_131)) + ); + assert_eq!( + timestamp("2026-09-29T10:00:00Z"), + Some(("2026-09-29 10:00:00 UTC".into(), 1_790_676_000)) ); assert_eq!( - timestamp("2026-09-29T10:00:00Z").as_deref(), - Some("2026-09-29 10:00:00 UTC") + timestamp("1970-01-01T00:00:00Z"), + Some(("1970-01-01 00:00:00 UTC".into(), 0)) ); for other in [ "2026-09-29T10:00:00+02:00", diff --git a/src/startup.rs b/src/startup.rs index ba81dac..78e9b95 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -107,6 +107,7 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result global.output_format(), global.show_secrets, global.output_file.as_deref().filter(|_| reserve), + global.color(), ) }; // Price commands need neither configuration nor credentials. diff --git a/src/style.rs b/src/style.rs index 4b17d06..269cc0a 100644 --- a/src/style.rs +++ b/src/style.rs @@ -229,6 +229,11 @@ pub fn write_stderr(choice: ColorChoice, text: &str) { let _ = stderr.flush(); } +/// Locked stdout that keeps styles only when `choice` allows color there. +pub fn stdout(choice: ColorChoice) -> anstream::AutoStream> { + anstream::AutoStream::new(std::io::stdout().lock(), choice.as_stream()) +} + /// Help and parse-error styles in cargo's manner, using only the 16 basic colors so the /// terminal's theme keeps them legible. pub const HELP: Styles = Styles::styled() diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 8e719b4..c4195e4 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -3945,6 +3945,39 @@ async fn api_rejections_lead_with_the_reason_and_keep_the_response_for_json() { assert_eq!(report["error"]["detail"]["data"], rejection); } +#[tokio::test(flavor = "multi_thread")] +async fn tables_follow_the_color_choice_on_stdout_and_strip_to_the_plain_table() { + let (server, dir) = fixture().await; + route("GET", payments_path()) + .respond_with(ok(json!({"items": [ + {"id": RESOURCE, "status": "completed", "amount": {"amount": 150000, "currency": "btc"}}, + {"id": WALLET, "status": "failed", "amount": {"amount": 1500, "currency": "btc"}} + ], "has_more": false}))) + .mount(&server) + .await; + let list = |color: &str| { + let result = Command::from_std(human_process_at(dir.path(), &server.uri())) + .args([ + "payments", "list", "--org", ORG, "--env", ENV, "--output", "table", "--color", + color, + ]) + .assert() + .success(); + stdout(&result) + }; + let plain = list("auto"); + assert!(!plain.contains('\u{1b}'), "{plain}"); + assert!( + plain.contains("150 sats") && plain.contains("1.5 sats"), + "{plain}" + ); + let colored = list("always"); + assert!(colored.contains("\u{1b}[32mcompleted"), "{colored:?}"); + assert!(colored.contains("\u{1b}[1m\u{1b}[31mfailed"), "{colored:?}"); + assert_eq!(strip_styles(&colored), plain); + assert_eq!(list("never"), plain); +} + #[tokio::test(flavor = "multi_thread")] async fn human_output_fits_the_terminal_and_scrubs_api_text() { let (server, dir) = fixture().await; @@ -3969,7 +4002,7 @@ async fn human_output_fits_the_terminal_and_scrubs_api_text() { assert_eq!( stdout(&listed), format!( - "{:<36} {:<9} AMOUNT\n{RESOURCE} completed 150000 msats\n\ + "{:<36} {:<9} AMOUNT\n{RESOURCE} completed 150 sats\n\ Hidden columns: created_at. Use a wider terminal or --json for every field.\n\ 1 result.\n", "ID", "STATUS"