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"