diff --git a/AGENTS.md b/AGENTS.md index 5c2ad78..8941278 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,9 @@ easy thing to add: Everything about how output looks lives in `src/output/`: `mod.rs` is the entry point (modes, `emit`, tips), `error.rs` shapes and prints failures, `render.rs` builds tables and field lists, `style.rs` decides when color is -allowed, and `banner.rs` is the version line a run opens with. +allowed, `theme.rs` is the palette — every color the CLI uses, each held to +a contrast rule by a test — and `banner.rs` is the version line a run opens +with. Four modules may touch stdout, and the guard lists each with its reason: `output/mod.rs`, which is the machinery; `completion.rs`, because a shell @@ -113,6 +115,12 @@ saying what to do. **If a guard fails, the fix is almost never to add yourself to its list** — read the reason first. When it genuinely is, add the entry *and* the sentence explaining it. +One table outside that file works the same way: `GROUPS` in +`src/help_layout.rs` places every command in a top-level help group, and +`every_command_has_a_place` fails when a new command has no place there or +an entry outlives its command. A command it misses still shows, under +"Other", but CI will not let that ship. + ## What the tests can and cannot tell you `cargo test` answers to fake tokens and a fake server. It runs offline, on a diff --git a/CHANGELOG.md b/CHANGELOG.md index db1b9e8..fdf4ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,56 @@ that may never merge. They are not releases and are not listed here. ## Unreleased +- `mapbox usage` reads as a chart: one row per product with no blank + lines between, every sparkline as wide as the period, bars scaled from + zero so their height is proportional to the value, and the tallest + stopping short of the row above. Each row is to its own scale and ends + with the peak its tallest bar stands for (`peak 407/day`). A day with any + usage never draws as a day without. + At a terminal the title and bars are in the accent over a muted + baseline, and the tips look like every other command's. The footer is + one line (`31 active days · generated 2026-09-08 09:49 UTC`), and the + `TOTAL` column lines up under its header. Text output only. + +- An API parameter's help line no longer stops at the first dot inside an + id, a number or "e.g.": `tilesets get-tile` described `` as + "Tileset ID(s) in the format `username" and now shows the whole first + sentence. Help text only; `--schema` always had the full description. + +- Color reads on light and dark terminal themes alike. Tables, tips, the + banner and help use a small fixed palette (teal, gray, red) instead of + the terminal's own bright blue and dim, which fell below 2:1 on some + light themes; every colored run of text is bold. Help asks the terminal + for its background and uses a palette made for a dark or a light one; + everything else, and help where the terminal doesn't answer, uses one + that reads on both. A terminal not known to show 24-bit color gets bold + and plain text instead. New `--no-color` flag turns color off, as + `NO_COLOR` does, and `FORCE_COLOR=1` now keeps color in a pipe; either + way off wins. + +- Top-level `mapbox --help` lists commands in groups (Maps and data, + Search, Account, Coding agents, CLI), each with a line saying what it + does rather than which API it wraps, and the global options under + Authentication, Output and Behavior, wrapped to the terminal. At a + terminal, help — top-level or a command's — opens with the + `mapbox · v` banner, as a command run does. + Subcommand help uses clap's compact layout for `--help` as well as + `-h`, with the command's full description at the top, and points to + `mapbox --help` for the global options instead of repeating them; they + still work on every command. Headings are teal; names to type and values + to fill in are bold in the terminal's own foreground; notes are gray. clap's yellow and green in usage errors + are bold now too. + Help text only: no command, flag, exit code or result output changes. + +- Top-level `mapbox --help` / `mapbox help` changes. It ends with a + "Learn more" section linking the CLI docs, the agent-setup prompt, + Mapbox Agent Skills, the Mapbox MCP server and the API docs. When a + coding agent runs it (detected the same way as for the `User-Agent`), + it opens with a two-line hint pointing at `mapbox --schema`. Help also + wraps to the terminal width (100 columns when piped), and env-backed + options read `[env: NAME]` without the variable's value. Help text + only: no flag, exit code or result output changes. + - **Breaking**: `--eta-type`, `--navigation-profile` and `--origin` removed from `mapbox search category`. The category endpoint does not currently return an ETA, so the flags were accepted but had no effect. A script diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8374056..547b95e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,6 +76,11 @@ where the specs are maintained, which is inside Mapbox. editable: hand-authored specs for APIs Mapbox publishes no description for. See its own README. +A new command, generated or hand-written, also needs a place in top-level +`mapbox --help`: add it to `GROUPS` in `src/help_layout.rs`, and give an API +command a one-line description in `DESCRIPTIONS` there, since its own is the +spec's title. `every_command_has_a_place` fails until it has both. + `build.rs` records which upstream commit the vendored specs came from, when that information is available to it. It is best-effort and can never fail a build — read its header before changing it. diff --git a/Cargo.lock b/Cargo.lock index c1cdfbe..6473342 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -162,6 +162,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] @@ -302,6 +303,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "filetime" version = "0.2.29" @@ -747,6 +758,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.3" @@ -784,6 +801,8 @@ dependencies = [ "serde_yaml", "sha2", "tar", + "terminal-colorsaurus", + "terminal_size", ] [[package]] @@ -1065,6 +1084,19 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.45" @@ -1307,6 +1339,42 @@ dependencies = [ "libc", ] +[[package]] +name = "terminal-colorsaurus" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a46bb5364467da040298c573c8a95dbf9a512efc039630409a03126e3703e90" +dependencies = [ + "cfg-if", + "libc", + "memchr", + "mio", + "terminal-trx", + "windows-sys 0.61.2", + "xterm-color", +] + +[[package]] +name = "terminal-trx" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3f27d9a8a177e57545481faec87acb45c6e854ed1e5a3658ad186c106f38ed" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1790,6 +1858,12 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "xterm-color" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7008a9d8ba97a7e47d9b2df63fcdb8dade303010c5a7cd5bf2469d4da6eba673" + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 7c38069..08fc692 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,11 @@ todo = "deny" unimplemented = "deny" [dependencies] -clap = { version = "4", features = ["derive", "env", "string"] } +clap = { version = "4", features = ["derive", "env", "string", "wrap_help"] } +# Already in the graph through clap's `wrap_help`. Used directly by the +# top-level help, which src/help_layout.rs renders itself and so has to wrap +# to the terminal itself. +terminal_size = "0.4" # Renders `mapbox completion ` from the same `Command` tree everything # else here is built from. Kept to the default features on purpose: the # dynamic-completion half is `unstable-dynamic`, and value completion is out @@ -99,6 +103,13 @@ dirs = "5" # are needed for `parse_to_serde_value`, which decodes straight into a # `serde_json::Value`. jsonc-parser = { version = "0.34.0", features = ["serde", "serde_json"] } +# Asks the terminal for its background color (OSC 11), so help can use the +# palette for a dark or a light one. It reads the answer off the terminal, +# which takes raw mode and a timed read — `unsafe` this crate does not +# have — and it sends a second query every terminal answers, so one without +# OSC 11 is known at once instead of after the timeout. Asked only by a run +# that shows help; see src/output/theme.rs. +terminal-colorsaurus = "1" [dev-dependencies] # Integration tests build fake Mapbox tokens; same crate the CLI already uses. diff --git a/README.md b/README.md index 6847486..def57bd 100644 --- a/README.md +++ b/README.md @@ -468,10 +468,15 @@ one flat object: `code`, `message`, plus `fix`, `next_actions` and `docs` where there is advice to give. See [docs/commands.md](./docs/commands.md#errors) for the list of codes. -At a terminal, a run opens with a `mapbox · v` line on stderr, and -tables, labels and tips are in color. Neither reaches a pipe or a file. -`-q`/`--quiet` (or `MAPBOX_QUIET=1`) hides the banner; `NO_COLOR` turns -color off. +At a terminal, a run — and any `--help` — opens with a +`mapbox · v` line on stderr, and help, tables, labels and tips +are in color. Neither reaches a pipe or a file. `-q`/`--quiet` (or +`MAPBOX_QUIET=1`) hides the banner. `--no-color` or `NO_COLOR` turns color +off; `FORCE_COLOR=1` keeps it on into a pipe, for a pager such as +`less -R`, and `--no-color`/`NO_COLOR` win over it. The colors are fixed +and chosen to read on light and dark themes alike; help asks the terminal +for its background to pick a palette for it. A terminal not known to show +24-bit color gets bold text instead. ### `--schema` diff --git a/docs/commands.md b/docs/commands.md index 35d8108..7ec7a64 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -538,6 +538,7 @@ either. | `--output`, `-o` | `auto` \| `text` \| `json`. | | `--id ` | On a command that returns a list, print just the row with that `id` or `name`. | | `--quiet`, `-q` | Don't print the `mapbox · v` banner, which goes to stderr and only when stderr is a terminal. Also `MAPBOX_QUIET`. | +| `--no-color` | Turn off color in help, errors and text output. Same as `NO_COLOR`; wins over `FORCE_COLOR`. | | `--timeout ` | How long one request may take, connection included. Defaults to 60 seconds, or 900 for a body read from `--file` or from a `--data @`/`@-`. Also `MAPBOX_TIMEOUT`. | An operation with a request body takes `--data`/`-d` when that body is text @@ -4179,15 +4180,11 @@ every line `-o text` prints around the table, is exactly what came back. Usage · 2026-08-09 → 2026-09-08 PRODUCT TOTAL DAILY TREND -Directions API 67,840,000 ▁▄▇▆▆▅▅▇▇▇▇▆▅▅▄▇▇██▇▇▆▅▅▇▇██▇▇▆ +Directions API 67,840,000 ▄▅▇▆▆▆▆▇▇▇▇▆▆▆▅▇▇▇▇▇▇▆▆▆▇▇▇▇▇▇▆ peak 2,618,385/day +Matrix API 45,260,000 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ peak 1,460,000/day +Vector Tiles API 6,300 ▁▁▁▁▁▁▇▁▁▁▁▁▁▄▁▁▁▄▁▁▄▁▁▁▁▁▁▁▁▁▁ peak 3,300/day -Matrix API 45,260,000 ▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅ - -Vector Tiles API 6,300 ▆▁▆▁▁▁█▁▁▁▁▁▁█▁▁▁█▆▁█▄▁▁▁▁▄▁▁▁▁ - -Active days: 31 - -Generated 2026-09-08T09:49:10.827Z +31 active days · generated 2026-09-08 09:49 UTC Tips: `-o json` for the exact per-day numbers and the per-browser/country/host breakdown. @@ -4227,10 +4224,17 @@ JSON `-o json` prints. Each row is a product's total for the period plus a sparkline of its daily values — a padded one: a day the API's `daily` array leaves out (it omits a day rather than sending `usage: 0` for it) still gets its own zero-height glyph at the right position in the line, computed from -`data.period`'s own start and end. Rows have a blank line between them — -without it, a sparkline's solid glyphs sitting flush against the next -row's read as cramped rather than dense, on an account with more than a -couple of products. Exact per-day numbers and the per-browser/country/host +`data.period`'s own start and end, so every product's line is the same +width. Bars scale from zero to the product's own busiest day, the way +ratatui's sparkline scales, so a bar's height is proportional to its value +and a product used the same amount every day is at the top every day +(`Matrix API` above). Since every row is to its own scale, each line ends +with `peak N/day`, the day's usage its tallest bar stands for. A day with any +usage is never drawn as the zero glyph. +Rows stack one per line, and the tallest bar stops at `▇` rather than `█`, +which leaves a gap under the row above so the products don't run +together. At a terminal the bars are in the accent color over a muted +baseline. Exact per-day numbers and the per-browser/country/host breakdown are left to `-o json`; `--product` narrows the whole response, both columns, to one product's row. A 403 covers two different causes the API doesn't otherwise distinguish: the token missing `statistics:read` — a @@ -4259,9 +4263,7 @@ Directions API — total 67,840,000 2026-08-10 2,150,000 2026-08-09 2,100,000 -Active days: 31 - -Generated 2026-09-08T09:49:10.827Z +31 active days · generated 2026-09-08 09:49 UTC Tips: `-o json` for the per-browser/country/host breakdown. diff --git a/src/account_usage.rs b/src/account_usage.rs index 113ed42..39dcf16 100644 --- a/src/account_usage.rs +++ b/src/account_usage.rs @@ -14,7 +14,7 @@ use serde_json::Value; use crate::executor; use crate::http; -use crate::output::{self, CliError, Mode}; +use crate::output::{self, style, CliError, Mode}; use crate::remedy::Remedy; use crate::spec::{parse_spec, Operation}; // `--help` points at the tracker rather than at one issue in it: issue @@ -73,7 +73,7 @@ fn operation() -> &'static Operation { pub fn command() -> Command { Command::new(COMMAND) - .about("Show account/token usage by product and day (Statistics API)") + .about("Show account and token usage by product and day") .long_about(format!( "Show usage per Mapbox product, by day, for the account or one token.\n\n\ Calls the Statistics API; the token needs the `statistics:read` scope. \ @@ -143,7 +143,12 @@ pub fn run( // `output::emit_value`'s generic renderer can't table this (nested // `daily`/`dimensions` per product), so `render_text` is a dedicated // summary; `-o json` still gets the response untouched. - let text = render_text(&json, matches.get_flag(DAILY_ARG), wanted_product.is_some()); + let text = render_text( + &json, + matches.get_flag(DAILY_ARG), + wanted_product.is_some(), + output::result_in_color(), + ); output::emit(mode, &text, json) } @@ -244,7 +249,11 @@ fn display_name(name: &str) -> String { /// Person-readable summary of a Statistics API response: one line per /// product (sparkline of daily values), or, under `--daily`, its usage /// listed day by day. Falls back to pretty JSON if `data` is missing. -fn render_text(json: &Value, daily: bool, already_filtered: bool) -> String { +/// +/// `color` styles the same text: the title in the accent, the header bold, +/// a sparkline's bars in the accent over a muted baseline, and the tips the +/// way every command's are. Stripped of it, the text is unchanged. +fn render_text(json: &Value, daily: bool, already_filtered: bool, color: bool) -> String { let Some(data) = json.get("data") else { return pretty(json); }; @@ -254,9 +263,10 @@ fn render_text(json: &Value, daily: bool, already_filtered: bool) -> String { let period = data.get("period"); let start = period.and_then(|p| p.get("start")).and_then(Value::as_str); let end = period.and_then(|p| p.get("end")).and_then(Value::as_str); + let title = style::paint("Usage", &style::accent(), color); lines.push(match (start, end) { - (Some(start), Some(end)) => format!("Usage · {start} → {end}"), - _ => "Usage".to_string(), + (Some(start), Some(end)) => format!("{title} · {start} → {end}"), + _ => title, }); if let Some(token_id) = data.get("token_id").and_then(Value::as_str) { @@ -293,22 +303,27 @@ fn render_text(json: &Value, daily: bool, already_filtered: bool) -> String { .map(|(name, ..)| display_name(name).chars().count()) .max() .unwrap_or(0); + // At least the header's own width, or `TOTAL` overhangs the + // numbers under it and pushes the trend column out of line. let total_width = rows .iter() .map(|(_, total, _)| with_thousands(*total).len()) + .chain((!daily).then_some("TOTAL".len())) .max() .unwrap_or(0); if !daily { - lines.push(format!( + let header = format!( "{:total_width$} DAILY TREND", "PRODUCT", "TOTAL" - )); + ); + lines.push(style::paint(&header, style::BOLD, color)); } - // One blank line between rows, not after every one, for - // readability without doubling the table's height. + // A sparkline table is one row per product, so the bars stack + // into a chart; the `--daily` listing is a block per product, + // and those need a blank line between them to read as blocks. for (index, (name, total, entries)) in rows.into_iter().enumerate() { - if index > 0 { + if daily && index > 0 { lines.push(String::new()); } if daily { @@ -325,11 +340,21 @@ fn render_text(json: &Value, daily: bool, already_filtered: bool) -> String { } } else { let values: Vec = entries.iter().map(|(_, usage)| *usage).collect(); + // Each row is to its own scale, so its top bar means a + // different number on every row: say which, the way + // Tufte labels a sparkline, in small text after it. + let peak = values.iter().copied().max().filter(|&max| max > 0); + let label = peak + .map(|max| { + let text = format!("peak {}/day", with_thousands(max)); + format!(" {}", style::paint(&text, &style::muted(), color)) + }) + .unwrap_or_default(); lines.push(format!( - "{:total_width$} {}", + "{:total_width$} {}{label}", display_name(name), with_thousands(total), - sparkline(&values) + styled_sparkline(&values, color) )); } } @@ -337,14 +362,22 @@ fn render_text(json: &Value, daily: bool, already_filtered: bool) -> String { _ => lines.push("No usage in this period.".to_string()), } + // What the numbers are, rather than the numbers: one muted line. + let mut footer = Vec::new(); if let Some(active_days) = data.get("activeDays").and_then(Value::as_array) { - lines.push(String::new()); - lines.push(format!("Active days: {}", active_days.len())); + footer.push(match active_days.len() { + 1 => "1 active day".to_string(), + n => format!("{n} active days"), + }); } - if let Some(generated_at) = json.get("generated_at").and_then(Value::as_str) { + footer.push(format!("generated {}", readable_timestamp(generated_at))); + } + if !footer.is_empty() { + let mut footer = footer.join(" · "); + footer[..1].make_ascii_uppercase(); lines.push(String::new()); - lines.push(format!("Generated {generated_at}")); + lines.push(style::paint(&footer, &style::muted(), color)); } let mut tips = vec![if daily { @@ -363,21 +396,28 @@ fn render_text(json: &Value, daily: bool, already_filtered: bool) -> String { } } - // The same shape every command's tips use: a lone tip reads `Tip: …`; - // two or more get a `Tips:` header with each one indented below it. + // Every command's tips, in their shape and style. lines.push(String::new()); - if let [tip] = tips.as_slice() { - lines.push(format!("Tip: {tip}")); - } else { - lines.push("Tips:".to_string()); - for tip in tips { - lines.push(format!(" {tip}")); - } - } + lines.extend(output::tip_lines(&tips, color)); lines.join("\n") } +/// `2026-10-08T14:30:12.166Z` as `2026-10-08 14:30 UTC`; anything else as +/// it came. +fn readable_timestamp(raw: &str) -> String { + let (date, time) = match raw.split_once('T') { + Some(parts) if raw.ends_with('Z') => parts, + _ => return raw.to_string(), + }; + match time.get(..5) { + Some(minutes) if date.len() == 10 && minutes.as_bytes()[2] == b':' => { + format!("{date} {minutes} UTC") + } + _ => raw.to_string(), + } +} + /// `1234567` as `"1,234,567"`. fn with_thousands(n: i64) -> String { let negative = n < 0; @@ -522,32 +562,61 @@ pub(crate) fn civil_from_days(z: i64) -> (i64, u32, u32) { (y, m, d) } -/// Block-element glyphs, low to high. -const SPARK_LEVELS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; - -/// Sparkline scaled to the series' own min/max. A flat nonzero series uses -/// the middle glyph rather than the bottom one, which is reserved for an -/// actual zero-usage day. -fn sparkline(values: &[i64]) -> String { - let (Some(&min), Some(&max)) = (values.iter().min(), values.iter().max()) else { - return String::new(); - }; - - if min == max { - let level = if max > 0 { SPARK_LEVELS.len() / 2 } else { 0 }; - return SPARK_LEVELS[level].to_string().repeat(values.len()); - } +/// Block-element glyphs, low to high — the set `spark` and ratatui use, short +/// of the full block. The lowest is reserved for a day with no usage. `█` +/// fills its cell, so on stacked rows a busy day's bar met the baseline of +/// the row above and the products ran together; `▇` leaves a gap. +const SPARK_LEVELS: [char; 7] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇']; +/// Each value's glyph index, scaled from zero to the series' maximum as +/// ratatui's sparkline scales, so a bar's height is proportional to the +/// value rather than to its distance from the quietest day. +/// +/// Each product is to its own scale, so a series with one value — tileset +/// hosting at 1 a day — is at its busiest every day it has usage and draws +/// at the top glyph, which `SPARK_LEVELS` already keeps short of the row +/// above. +/// +/// One departure, for reading without color: a day with any usage takes at +/// least the second glyph, so it never looks like a day without — ratatui +/// rounds a small value down to empty. +fn spark_levels(values: &[i64]) -> Vec { + let top = SPARK_LEVELS.len() - 1; + let max = values.iter().copied().max().unwrap_or(0); values .iter() - .map(|&value| { - let scaled = (value - min) as f64 / (max - min) as f64; - let index = (scaled * (SPARK_LEVELS.len() - 1) as f64).round() as usize; - SPARK_LEVELS[index.min(SPARK_LEVELS.len() - 1)] + .map(|&value| match value { + v if v <= 0 => 0, + v => 1 + ((v as f64 / max as f64) * (top - 1) as f64).round() as usize, }) .collect() } +/// The sparkline as plain text: one glyph per day, so every product's line +/// is as wide as the period. +fn sparkline(values: &[i64]) -> String { + spark_levels(values) + .into_iter() + .map(|level| SPARK_LEVELS[level]) + .collect() +} + +/// The sparkline in color: bars in the accent, the no-usage baseline muted. +fn styled_sparkline(values: &[i64], color: bool) -> String { + if !color { + return sparkline(values); + } + let (accent, muted) = (style::accent(), style::muted()); + let levels = spark_levels(values); + let mut out = String::new(); + for run in levels.chunk_by(|a, b| (*a == 0) == (*b == 0)) { + let glyphs: String = run.iter().map(|&level| SPARK_LEVELS[level]).collect(); + let paint = if run[0] == 0 { &muted } else { &accent }; + out.push_str(&style::paint(&glyphs, paint, true)); + } + out +} + fn pretty(json: &Value) -> String { serde_json::to_string_pretty(json).unwrap_or_else(|_| json.to_string()) } @@ -745,7 +814,7 @@ mod tests { #[test] fn the_summary_totals_each_products_daily_usage() { - let text = render_text(&documented_example(), false, false); + let text = render_text(&documented_example(), false, false, false); assert!(text.contains("Usage · 2024-01-01 → 2024-01-02"), "{text}"); assert!(text.contains("Token: abc123"), "{text}"); let row = text @@ -753,13 +822,15 @@ mod tests { .find(|line| line.contains("Vector Tiles API")) .expect("the product has its own line"); assert!(row.contains("200"), "{row}"); // 100 + 100, not just the first - assert!(text.contains("Active days: 2"), "{text}"); - assert!(text.contains("2024-01-31T10:30:00Z"), "{text}"); + assert!( + text.contains("2 active days · generated 2024-01-31 10:30 UTC"), + "{text}" + ); } #[test] fn per_day_dates_and_numbers_stay_out_of_the_text_summary() { - let text = render_text(&documented_example(), false, false); + let text = render_text(&documented_example(), false, false, false); // Each date appears once, naming the period's boundaries — not // again per product, which is where a per-day breakdown would // have repeated it. @@ -779,7 +850,7 @@ mod tests { #[test] fn daily_lists_each_day_instead_of_a_sparkline() { - let text = render_text(&documented_example(), true, false); + let text = render_text(&documented_example(), true, false, false); assert!(text.contains("Vector Tiles API — total 200"), "{text}"); assert!(text.contains("2024-01-01"), "{text}"); assert!(text.contains("2024-01-02"), "{text}"); @@ -796,7 +867,7 @@ mod tests { #[test] fn daily_lists_the_newest_day_first() { - let text = render_text(&documented_example(), true, false); + let text = render_text(&documented_example(), true, false, false); let newest = text .lines() .position(|line| line.trim_start().starts_with("2024-01-02")) @@ -810,10 +881,10 @@ mod tests { #[test] fn a_hint_toward_daily_appears_only_without_it() { - let with_sparkline = render_text(&documented_example(), false, false); + let with_sparkline = render_text(&documented_example(), false, false, false); assert!(with_sparkline.contains("--daily"), "{with_sparkline}"); - let with_daily = render_text(&documented_example(), true, false); + let with_daily = render_text(&documented_example(), true, false, false); assert!(!with_daily.contains("--daily"), "{with_daily}"); } @@ -828,13 +899,13 @@ mod tests { } }); - let unfiltered = render_text(&json, false, false); + let unfiltered = render_text(&json, false, false, false); assert!( unfiltered.contains("--product \"Busy API\""), "{unfiltered}" ); - let filtered = render_text(&json, false, true); + let filtered = render_text(&json, false, true, false); assert!( !filtered.contains("--product"), "a response already narrowed by --product should not suggest it again:\n{filtered}" @@ -846,7 +917,7 @@ mod tests { let json = serde_json::json!({ "data": { "products": { "Only API": { "daily": [{ "date": "2026-01-01", "usage": 1 }] } } } }); - let text = render_text(&json, false, false); + let text = render_text(&json, false, false, false); assert!( !text.contains("--product"), "nothing left to narrow when there is already only one product:\n{text}" @@ -879,7 +950,7 @@ mod tests { #[test] fn the_sparkline_table_has_a_column_header() { - let text = render_text(&documented_example(), false, false); + let text = render_text(&documented_example(), false, false, false); assert!( text.contains("PRODUCT") && text.contains("TOTAL") && text.contains("DAILY TREND"), "{text}" @@ -888,7 +959,7 @@ mod tests { #[test] fn the_daily_listing_has_no_column_header() { - let text = render_text(&documented_example(), true, false); + let text = render_text(&documented_example(), true, false, false); assert!(!text.contains("DAILY TREND"), "{text}"); } @@ -904,18 +975,18 @@ mod tests { } }); assert!( - render_text(&json, false, false).contains("1,234,567"), + render_text(&json, false, false, false).contains("1,234,567"), "sparkline mode" ); assert!( - render_text(&json, true, false).contains("1,234,567"), + render_text(&json, true, false, false).contains("1,234,567"), "daily mode" ); } #[test] fn tips_are_labeled_and_each_on_their_own_line() { - let text = render_text(&documented_example(), false, false); + let text = render_text(&documented_example(), false, false, false); let tips_at = text .lines() .position(|line| line == "Tips:") @@ -937,7 +1008,7 @@ mod tests { } } }); - let text = render_text(&json, false, false); + let text = render_text(&json, false, false, false); let busy = text.find("Busy API").expect("Busy API is listed"); let quiet = text.find("Quiet API").expect("Quiet API is listed"); assert!( @@ -947,7 +1018,7 @@ mod tests { } #[test] - fn product_rows_have_breathing_room_between_them_but_not_a_trailing_gap() { + fn product_rows_stack_into_a_chart_with_the_footer_after_a_gap() { // Not single letters: the header row's own words contain enough of // the alphabet to match a one-letter product name by accident. let json = serde_json::json!({ @@ -960,36 +1031,139 @@ mod tests { }, "generated_at": "2026-01-02T00:00:00Z" }); - let text = render_text(&json, false, false); + let text = render_text(&json, false, false, false); let lines: Vec<&str> = text.lines().collect(); let product_line = |name: &str| lines.iter().position(|l| l.contains(name)).unwrap(); - // Busiest first: Charlie (3), Bravo (2), Alpha (1). + // Busiest first: Charlie (3), Bravo (2), Alpha (1), one per line. assert_eq!( - product_line("Bravo Product") - product_line("Charlie Product"), - 2, + product_line("Bravo Product"), + product_line("Charlie Product") + 1, "{text}" ); assert_eq!( - product_line("Alpha Product") - product_line("Bravo Product"), - 2, + product_line("Alpha Product"), + product_line("Bravo Product") + 1, "{text}" ); let last_row = product_line("Alpha Product"); assert!(lines[last_row + 1].is_empty(), "{text}"); - assert!(lines[last_row + 2].starts_with("Generated"), "{text}"); + assert_eq!( + lines[last_row + 2], + "Generated 2026-01-02 00:00 UTC", + "{text}" + ); + } + + /// Each row's own scale is named after its sparkline: the value its top + /// bar stands for. An idle row has no peak to name. + #[test] + fn each_sparkline_names_the_peak_its_top_bar_stands_for() { + let json = serde_json::json!({ + "data": { "products": { + "Busy API": { "daily": [ + { "date": "2026-01-01", "usage": 1500 }, { "date": "2026-01-02", "usage": 7 } + ] }, + "Idle API": { "daily": [{ "date": "2026-01-01", "usage": 0 }] } + } } + }); + let text = render_text(&json, false, false, false); + let row = |name: &str| text.lines().find(|l| l.contains(name)).expect("a row"); + assert!(row("Busy API").ends_with(" peak 1,500/day"), "{text}"); + assert!(!row("Idle API").contains("peak"), "{text}"); + } + + /// `TOTAL` is wider than a small total; the numbers sit right-aligned + /// under it, and every sparkline starts where `DAILY TREND` does. + #[test] + fn the_columns_line_up_under_the_header() { + let json = serde_json::json!({ + "data": { "products": { + "Busy API": { "daily": [{ "date": "2026-01-01", "usage": 407 }] }, + "Quiet API": { "daily": [{ "date": "2026-01-01", "usage": 1 }] } + } } + }); + let text = render_text(&json, false, false, false); + let header = text + .lines() + .find(|l| l.starts_with("PRODUCT")) + .expect("a header"); + let total_end = header.find("TOTAL").expect("TOTAL") + "TOTAL".len(); + let trend_at = header.find("DAILY TREND").expect("DAILY TREND"); + for row in text.lines().filter(|l| l.contains(" API ")) { + let chars: Vec = row.chars().collect(); + assert!( + chars[total_end - 1].is_ascii_digit(), + "total not under TOTAL: {row:?}" + ); + assert!( + SPARK_LEVELS.contains(&chars[trend_at]), + "trend not under its header: {row:?}" + ); + } + } + + /// Every product's sparkline is one glyph per day of the period, so the + /// lines are the same width however sparse a product's data — a day the + /// API left out is drawn as a day without usage. + #[test] + fn every_sparkline_is_as_wide_as_the_period() { + let json = serde_json::json!({ + "data": { + "period": { "start": "2026-01-01", "end": "2026-01-05" }, + "products": { + "Busy API": { "daily": [ + { "date": "2026-01-01", "usage": 5 }, { "date": "2026-01-02", "usage": 1 }, + { "date": "2026-01-03", "usage": 9 }, { "date": "2026-01-04", "usage": 2 }, + { "date": "2026-01-05", "usage": 7 } + ] }, + "Sparse API": { "daily": [{ "date": "2026-01-03", "usage": 1 }] } + } + } + }); + let text = render_text(&json, false, false, false); + let widths: Vec = text + .lines() + .filter(|line| line.contains(" API ")) + .map(|line| line.chars().filter(|c| SPARK_LEVELS.contains(c)).count()) + .collect(); + assert_eq!(widths, [5, 5], "{text}"); + } + + /// Color changes how the text looks, never what it says: the bars take + /// the accent and the no-usage baseline is muted. + #[test] + fn color_styles_the_bars_and_the_baseline_and_nothing_else() { + let plain = render_text(&documented_example(), false, false, false); + let colored = render_text(&documented_example(), false, false, true); + assert_eq!(style::strip(&colored), plain); + + let bars = styled_sparkline(&[0, 3, 0], true); + let (accent, muted) = (style::accent(), style::muted()); + assert!(bars.contains(&format!("{accent}▇")), "{bars:?}"); + if !muted.is_empty() { + assert!(bars.starts_with(&format!("{muted}▁")), "{bars:?}"); + } } #[test] fn no_products_reads_as_no_usage_rather_than_an_empty_table() { let json = serde_json::json!({ "data": { "products": {} } }); - assert!(render_text(&json, false, false).contains("No usage in this period.")); + assert!(render_text(&json, false, false, false).contains("No usage in this period.")); + } + + /// Its own scale: a series with one value is at its busiest every day. + #[test] + fn a_flat_nonzero_series_is_at_its_top_every_day() { + assert_eq!(sparkline(&[5, 5, 5]), "▇▇▇"); } + /// A day without usage stays on the baseline between days at the top. #[test] - fn a_flat_nonzero_series_reads_as_steady_rather_than_idle() { - assert_eq!(sparkline(&[5, 5, 5]), "▅▅▅"); + fn steady_usage_with_a_gap_shows_the_gap() { + assert_eq!(sparkline(&[1, 1, 1, 0, 1]), "▇▇▇▁▇"); + assert_eq!(sparkline(&[0, 0, 1, 0, 0]), "▁▁▇▁▁"); } #[test] @@ -997,11 +1171,33 @@ mod tests { assert_eq!(sparkline(&[0, 0, 0]), "▁▁▁"); } + /// Scaled from zero, not from the quietest day: a busy day next to a + /// quiet one stays tall, and the quiet one, having had usage, never takes + /// the no-usage glyph. + #[test] + fn a_day_with_any_usage_never_looks_like_a_day_without() { + let chars: Vec = sparkline(&[0, 1, 1000]).chars().collect(); + assert_eq!(chars, ['▁', '▂', '▇']); + } + + #[test] + fn readable_timestamp_shortens_only_what_it_recognizes() { + assert_eq!( + readable_timestamp("2026-10-08T14:30:12.166Z"), + "2026-10-08 14:30 UTC" + ); + assert_eq!( + readable_timestamp("2026-10-08T14:30:00+02:00"), + "2026-10-08T14:30:00+02:00" + ); + assert_eq!(readable_timestamp("yesterday"), "yesterday"); + } + #[test] fn the_extremes_of_a_series_take_the_extreme_glyphs() { let chars: Vec = sparkline(&[0, 50, 100]).chars().collect(); assert_eq!(chars[0], '▁'); - assert_eq!(chars[2], '█'); + assert_eq!(chars[2], '▇'); } #[test] @@ -1162,7 +1358,7 @@ mod tests { } } }); - let text = render_text(&json, false, false); + let text = render_text(&json, false, false, false); let line = text .lines() .find(|l| l.contains("Address Autofill")) @@ -1174,7 +1370,7 @@ mod tests { #[test] fn a_response_with_no_data_field_falls_back_to_pretty_json() { let json = serde_json::json!({ "message": "not what was expected" }); - let text = render_text(&json, false, false); + let text = render_text(&json, false, false, false); assert!(text.contains("not what was expected"), "{text}"); } diff --git a/src/generate_skills.rs b/src/generate_skills.rs index fe3e0e2..a192a21 100644 --- a/src/generate_skills.rs +++ b/src/generate_skills.rs @@ -2119,7 +2119,7 @@ mod tests { .expect("--output is global"); let described = describe(output); assert!( - described.contains("a terminal gets text"), + described.contains("text in a terminal, JSON when piped"), "the whole of --output's help should survive: {described}" ); @@ -2198,7 +2198,8 @@ mod tests { // The page heading, then straight to the command — no service // intro repeating what the command's own block says. let summary_line = page.contents.lines().find(|line| { - line.starts_with("Write Agent Skills") || line.starts_with("Proxy commands") + line.starts_with("Write Agent Skills") + || line.starts_with("Run the Mapbox Tilesets CLI") }); if let Some(summary) = summary_line { assert_eq!( diff --git a/src/help_layout.rs b/src/help_layout.rs new file mode 100644 index 0000000..8161e4a --- /dev/null +++ b/src/help_layout.rs @@ -0,0 +1,834 @@ +//! The layout of top-level `mapbox --help`: commands in named groups rather +//! than one flat list. +//! +//! Commands are grouped by what the reader is trying to do, not by how the +//! command is built — an API command and a hand-written one sit side by side +//! when they serve the same task: +//! +//! - **Maps and data**: create or fetch map content — styles, sprites, +//! fonts, tiles, static images. +//! - **Search**: find places and addresses, and report problems with them. +//! - **Account**: credentials, tokens and usage — who you are and what you +//! have used. +//! - **Coding agents**: wire Mapbox into a coding agent. +//! - **CLI**: manage the CLI itself. +//! +//! Routing APIs (Directions, Matrix, Isochrone, Map Matching, Optimization) +//! are expected to land as a "Navigation" group of their own. +//! +//! clap has no notion of command groups, and aligns each option heading on +//! its own column, so this renders the whole top-level page itself — one +//! column for every section — and hands it to clap as the root's help +//! template. Nothing else changes: the command tree that parsing, +//! completion, `--schema` and suggestions read is the one `build_app` built, +//! and subcommand help is clap's own, in the same [`styles`]. +//! +//! A command missing from [`GROUPS`] still shows, under "Other", so a slip +//! here cannot hide a command — but `every_command_has_a_place` fails first. + +use clap::builder::styling::{Style, Styles}; +use clap::builder::StyledStr; +use clap::{Arg, Command}; + +/// Help groups in display order, each listing its commands in display order. +const GROUPS: &[(&str, &[&str])] = &[ + ( + "Maps and data", + &["styles", "sprites", "fonts", "tilesets", "static"], + ), + ("Search", &["search", "geocoder", "feedback"]), + ("Account", &["auth", "accounts", "usage"]), + ("Coding agents", &["mcp", "agent-skills", "generate-skills"]), + ( + "CLI", + &[ + "config", + "history", + "doctor", + "completion", + "tilesets-cli", + "uninstall", + "help", + ], + ), +]; + +/// One-line descriptions for this list, where a command's own `about` does +/// not serve: the API commands, which carry their spec's title ("Mapbox +/// Tokens API") — that says which API, not what the command does — clap's +/// `help`, and a hand-written `about` too long for one line here. Used in +/// this list only; `--schema` and generated skills keep the command's own. +const DESCRIPTIONS: &[(&str, &str)] = &[ + ("styles", "Create, read, update and delete map styles"), + ("sprites", "Add and remove images in a style's sprite"), + ("fonts", "List, upload and delete fonts"), + ( + "tilesets", + "Fetch vector and raster tiles, and query features at a point", + ), + ("static", "Render static map images and tiles"), + ( + "search", + "Find addresses and places by text, coordinate or category", + ), + ("geocoder", "Forward, reverse and batch geocoding"), + ("feedback", "Submit and list feedback about Mapbox data"), + ("accounts", "List access tokens and their scopes"), + ( + "generate-skills", + "Write Agent Skills describing this CLI's own commands", + ), + ("help", "Print help for mapbox or a command"), +]; + +/// Where the rest of Mapbox's agent tooling lives. An agent that only +/// installed the CLI finds these here or nowhere: it reads `--help` when +/// stuck, and the install-time pointers are easy to skip. +/// `tests/output_contract.rs` checks each URL is printed, not that it still +/// resolves; that is checked by hand. +const LINKS: &[(&str, &str)] = &[ + ("CLI docs", "https://docs.mapbox.com/cli/"), + ( + "Agent setup", + "https://cli.mapbox.com/agent-setup/prompt.md", + ), + ( + "Agent Skills", + "https://github.com/mapbox/mapbox-agent-skills", + ), + ("MCP server", "https://github.com/mapbox/mcp-server"), + ("API docs", "https://docs.mapbox.com/api/overview/"), +]; + +const OTHER: &str = "Other"; +const OPTIONS: &str = "Options"; + +/// The widest the page gets, terminal or not: past 100 columns a +/// description runs too far from its flag to read as one line. +const MAX_WIDTH: usize = 100; + +/// Gives `app` a help template holding the whole top-level page. Call it +/// last: the page is rendered from the commands and arguments `app` already +/// has. +pub fn apply(app: Command) -> Command { + let mut sections = command_sections(&app); + sections.extend(option_sections(&app)); + sections.push(( + "Learn more".to_string(), + LINKS + .iter() + .map(|(label, url)| Row::new(label, label.to_string(), vec![Piece::plain(url)])) + .collect(), + Block::Names, + )); + + let page = render(§ions, page_width(), &Palette::new(app.get_styles())); + // Rendered now rather than through `{usage}`: help is shown by the + // parsing copy, whose global options are hidden (see `for_parsing`), and + // clap would drop `[OPTIONS]` from a usage line with none visible. + let usage = app.clone().render_usage().ansi().to_string(); + let template = format!("{{before-help}}{{about-with-newline}}\n{usage}\n\n{page}"); + app.help_template(StyledStr::from(template)) +} + +/// The styles this page uses, read from the ones clap was given (see +/// `output::theme`), so a test can render either palette. +struct Palette { + header: Style, + literal: Style, + placeholder: Style, + /// Notes and hints. clap calls it `context`: its `[env: …]` and + /// `[default: …]` notes, which these sit beside. + muted: Style, +} + +impl Palette { + fn new(styles: &Styles) -> Self { + Palette { + header: *styles.get_header(), + literal: *styles.get_literal(), + placeholder: *styles.get_placeholder(), + muted: *styles.get_context(), + } + } +} + +/// A word, or a run of words that must stay on one line, and how it looks. +/// `prefix` and `suffix` are plain punctuation touching a styled piece with +/// no space between: the parentheses in "(`tilesets`)". +struct Piece { + prefix: String, + text: String, + kind: Kind, + suffix: String, +} + +#[derive(Clone, Copy, PartialEq)] +enum Kind { + Plain, + Literal, + Note, +} + +impl Piece { + fn new(text: &str, kind: Kind) -> Self { + Piece { + prefix: String::new(), + text: text.to_string(), + kind, + suffix: String::new(), + } + } + + fn plain(text: &str) -> Self { + Piece::new(text, Kind::Plain) + } + + fn width(&self) -> usize { + [&self.prefix, &self.text, &self.suffix] + .iter() + .map(|s| s.chars().count()) + .sum() + } +} + +/// One line of a section: what to type (or a label) on the left, what it +/// does on the right. +struct Row { + left_width: usize, + left: String, + description: Vec, +} + +impl Row { + fn new(plain_left: &str, styled_left: String, description: Vec) -> Self { + Row { + left_width: plain_left.chars().count(), + left: styled_left, + description, + } + } +} + +/// A heading, its rows, and which column its descriptions start on. +type Section = (String, Vec, Block); + +/// Commands (and the link labels beside them) share one description column, +/// options another. One column for everything let the longest option — +/// `-u, --username ` — push every command's description a dozen +/// columns away from its name. +#[derive(Clone, Copy, PartialEq)] +enum Block { + Names, + Options, +} + +fn command_sections(app: &Command) -> Vec
{ + let literal = *app.get_styles().get_literal(); + grouped(rows(app)) + .into_iter() + .map(|(heading, rows)| { + let rows = rows + .into_iter() + .map(|(name, about)| { + Row::new(&name, format!("{literal}{name}{literal:#}"), pieces(&about)) + }) + .collect(); + (heading.to_string(), rows, Block::Names) + }) + .collect() +} + +/// The root's own options under their `help_heading`s, in the order the +/// headings first appear. `-h` and `-V` are clap's and carry no heading; +/// they close the last section rather than sit alone in one. +fn option_sections(app: &Command) -> Vec
{ + let palette = Palette::new(app.get_styles()); + let mut sections: Vec
= Vec::new(); + for arg in app + .get_arguments() + .filter(|a| !a.is_hide_set() && !a.is_positional()) + { + let heading = arg.get_help_heading().unwrap_or(OPTIONS); + let row = option_row(arg, &palette); + match sections.iter_mut().find(|(h, _, _)| h == heading) { + Some((_, rows, _)) => rows.push(row), + None => sections.push((heading.to_string(), vec![row], Block::Options)), + } + } + + let mut builtin = vec![flag_row(Some('h'), "help", None, "Print help", &palette)]; + if app.get_version().is_some() && !app.is_disable_version_flag_set() { + builtin.push(flag_row( + Some('V'), + "version", + None, + "Print version", + &palette, + )); + } + match sections.last_mut() { + Some((_, rows, _)) => rows.extend(builtin), + None => sections.push((OPTIONS.to_string(), builtin, Block::Options)), + } + sections +} + +fn option_row(arg: &Arg, palette: &Palette) -> Row { + let value = arg.get_action().takes_values().then(|| { + arg.get_value_names() + .and_then(|names| names.first().map(ToString::to_string)) + .unwrap_or_else(|| arg.get_id().as_str().to_uppercase()) + }); + let help = arg.get_help().map(ToString::to_string).unwrap_or_default(); + let env = arg.get_env().map(|e| e.to_string_lossy().into_owned()); + flag_row( + arg.get_short(), + arg.get_long().unwrap_or_default(), + value, + &help, + palette, + ) + .with_env(env) +} + +fn flag_row( + short: Option, + long: &str, + value: Option, + help: &str, + palette: &Palette, +) -> Row { + let (lit, ph) = (palette.literal, palette.placeholder); + let (short_plain, short_styled) = match short { + Some(c) => (format!("-{c}, "), format!("{lit}-{c}{lit:#}, ")), + None => (" ".to_string(), " ".to_string()), + }; + let (value_plain, value_styled) = match &value { + Some(v) => (format!(" <{v}>"), format!(" {ph}<{v}>{ph:#}")), + None => (String::new(), String::new()), + }; + let plain = format!("{short_plain}--{long}{value_plain}"); + let styled = format!("{short_styled}{lit}--{long}{lit:#}{value_styled}"); + Row::new(&plain, styled, pieces(help)) +} + +impl Row { + /// Appends the arg's environment variable as a note. A help text + /// that names one by hand — `--timeout` and `--output` read theirs + /// leniently rather than through clap — has its note kept whole the same way. + fn with_env(mut self, env: Option) -> Self { + if let Some(name) = env { + self.description + .push(Piece::new(&format!("[env: {name}]"), Kind::Note)); + } + self + } +} + +/// Splits help text into pieces: words, `code` spans kept whole and shown +/// without their backticks, and a trailing `[env: …]` note kept whole. +fn pieces(text: &str) -> Vec { + let (body, note) = match text.rfind(" [env: ") { + Some(at) if text.ends_with(']') => (&text[..at], Some(&text[at + 1..])), + _ => (text, None), + }; + + let mut out: Vec = Vec::new(); + // Text touching a code span with no space between — "(" before it, "," + // after it — belongs to the span, not to a word of its own. + let mut prefix = String::new(); + for (i, part) in body.split('`').enumerate() { + if i % 2 == 1 { + let mut piece = Piece::new(part, Kind::Literal); + piece.prefix = std::mem::take(&mut prefix); + out.push(piece); + continue; + } + let mut words: Vec<&str> = part.split_whitespace().collect(); + if !part.starts_with(char::is_whitespace) && !words.is_empty() { + if let Some(prev) = out.last_mut().filter(|p| p.kind == Kind::Literal) { + prev.suffix = words.remove(0).to_string(); + } + } + if !part.ends_with(char::is_whitespace) && i + 1 < body.split('`').count() { + prefix = words.pop().unwrap_or_default().to_string(); + } + out.extend(words.into_iter().map(Piece::plain)); + } + out.extend(note.map(|n| Piece::new(n, Kind::Note))); + out +} + +fn render(sections: &[Section], width: usize, palette: &Palette) -> String { + let column = |block: Block| { + 2 + sections + .iter() + .filter(|(_, _, b)| *b == block) + .flat_map(|(_, rows, _)| rows.iter().map(|r| r.left_width)) + .max() + .unwrap_or(0) + + 2 + }; + let header = palette.header; + + let mut page = String::new(); + for (heading, rows, block) in sections { + let column = column(*block); + let room = width.saturating_sub(column).max(20); + page.push_str(&format!("{header}{heading}:{header:#}\n")); + for row in rows { + page.push_str(" "); + page.push_str(&row.left); + let mut lines = wrap(&row.description, room).into_iter(); + if let Some(first) = lines.next() { + page.push_str(&" ".repeat(column - 2 - row.left_width)); + page.push_str(&line_text(&first, palette)); + } + for line in lines { + page.push('\n'); + page.push_str(&" ".repeat(column)); + page.push_str(&line_text(&line, palette)); + } + page.push('\n'); + } + page.push('\n'); + } + let lit = palette.literal; + page.push_str(&format!( + "{muted}Run{muted:#} {lit}mapbox --help{lit:#} \ + {muted}for a command's own options.{muted:#}", + muted = palette.muted, + )); + page +} + +/// Greedy word wrap of `pieces` into lines no wider than `room`. A piece +/// wider than `room` gets a line of its own rather than being split. +fn wrap(pieces: &[Piece], room: usize) -> Vec> { + let mut lines: Vec> = Vec::new(); + let mut current: Vec<&Piece> = Vec::new(); + let mut used = 0; + for piece in pieces { + let len = piece.width(); + let needed = if current.is_empty() { + len + } else { + used + 1 + len + }; + if !current.is_empty() && needed > room { + lines.push(std::mem::take(&mut current)); + used = len; + } else { + used = needed; + } + current.push(piece); + } + if !current.is_empty() { + lines.push(current); + } + lines +} + +/// A wrapped line's pieces, joined. Only notes are styled: in a +/// description a code span is prose, and the names on the left are what is +/// bold. +fn line_text(line: &[&Piece], palette: &Palette) -> String { + let muted = palette.muted; + line.iter() + .map(|piece| match piece.kind { + Kind::Note => format!( + "{}{muted}{}{muted:#}{}", + piece.prefix, piece.text, piece.suffix + ), + Kind::Plain | Kind::Literal => { + format!("{}{}{}", piece.prefix, piece.text, piece.suffix) + } + }) + .collect::>() + .join(" ") +} + +/// The terminal's width, as clap would read it, capped at [`MAX_WIDTH`]. +fn page_width() -> usize { + let detected = std::env::var("COLUMNS") + .ok() + .and_then(|c| c.parse::().ok()) + .filter(|c| *c > 0) + .or_else(|| terminal_size::terminal_size().map(|(w, _)| usize::from(w.0))); + detected.map_or(MAX_WIDTH, |w| w.min(MAX_WIDTH)) +} + +/// Whether argv asks for help, read before clap parses it: `-h` or `--help` +/// anywhere before `--`, or `help` as the first word that is not an option. +/// The one kind of run allowed to ask the terminal for its background (see +/// `output::theme::allow_background_query`), and that opens with the banner +/// before clap prints anything. A miss costs only the fallback +/// palette; a false match on a command that prompts would cost the answer +/// typed ahead, which is why `help` counts only as the subcommand. +pub fn help_requested(argv: &[std::ffi::OsString]) -> bool { + let mut first_word = true; + for arg in argv.iter().skip(1).filter_map(|arg| arg.to_str()) { + match arg { + "--" => return false, + "-h" | "--help" => return true, + "help" if first_word => return true, + _ if !arg.starts_with('-') => first_word = false, + _ => {} + } + } + false +} + +/// The copy of the tree that parses, and so renders every subcommand's +/// help: compact, and about the command itself. +/// +/// clap switches `--help` to its long layout whenever a command has a +/// `long_about`, and nearly every command here does: each option's +/// description on a line of its own, a blank line between options, and +/// `[env: …]` as a paragraph. Here the long description moves to the top of +/// the command's own page instead, which leaves clap nothing long to show. +/// +/// The eleven global options are listed on the top-level page and named in +/// one line on every other. Spelled out on each, they outweighed what the +/// command itself takes, and clap aligned each of their headings on a +/// column of its own. Hidden only from help: they still parse, and clap +/// still suggests them for a typo. +/// +/// Only the parsing copy: `--schema`, completion and `generate-skills` read +/// the tree `build_app` returned. Backticks go too — in a terminal they are +/// noise, not markup. +pub fn for_parsing(app: Command) -> Command { + let footer = global_options_footer(&app); + app.mut_args(|arg| { + let arg = plain_arg_help(arg); + if arg.is_global_set() { + arg.hide(true) + } else { + arg + } + }) + .mut_subcommands(|cmd| compact(cmd, &footer)) +} + +/// One line closing a subcommand's help, standing in for the global +/// options. Phrased like the top-level page's closing line, and like +/// kubectl's pointer to its global options. +fn global_options_footer(app: &Command) -> StyledStr { + let styles = app.get_styles(); + let (lit, muted) = (*styles.get_literal(), *styles.get_context()); + StyledStr::from(format!( + "{muted}Run{muted:#} {lit}mapbox --help{lit:#} \ + {muted}for the global options, which apply to every command.{muted:#}" + )) +} + +fn compact(cmd: Command, footer: &StyledStr) -> Command { + let long = cmd + .get_long_about() + .map(|text| without_backticks(&text.to_string())); + let about = cmd + .get_about() + .map(|text| without_backticks(&text.to_string())); + let mut cmd = cmd + .mut_args(plain_arg_help) + .mut_subcommands(|sub| compact(sub, footer)) + .after_help(footer.clone()); + if let Some(about) = about { + cmd = cmd.about(about); + } + match long { + // `before_help` shows on the command's own page and not in its + // parent's list, where `about` stays the one-line summary. + Some(long) => cmd + .long_about(None::<&'static str>) + .before_help(long) + .help_template("{before-help}{usage-heading} {usage}\n\n{all-args}{after-help}"), + None => cmd, + } +} + +fn plain_arg_help(arg: Arg) -> Arg { + match arg + .get_help() + .map(|help| without_backticks(&help.to_string())) + { + Some(help) => arg.help(help), + None => arg, + } +} + +fn without_backticks(text: &str) -> String { + text.replace('`', "") +} + +/// `(name, description)` for each command the help lists. clap adds its +/// `help` command only while building the tree, which has not happened yet +/// and should not happen early: `--schema` reads the unbuilt tree. +fn rows(app: &Command) -> Vec<(String, String)> { + let mut rows: Vec<(String, String)> = app + .get_subcommands() + .filter(|c| !c.is_hide_set()) + .map(|c| { + let name = c.get_name().to_string(); + let about = description(&name) + .map(str::to_string) + .or_else(|| c.get_about().map(ToString::to_string)) + .unwrap_or_default(); + (name, about) + }) + .collect(); + if !app.is_disable_help_subcommand_set() && !rows.is_empty() { + rows.push(( + "help".to_string(), + description("help").unwrap_or_default().to_string(), + )); + } + rows +} + +/// `rows` sorted into [`GROUPS`], with anything unlisted under "Other". +fn grouped(rows: Vec<(String, String)>) -> Vec<(&'static str, Vec<(String, String)>)> { + let mut groups: Vec<(&'static str, Vec<(String, String)>)> = GROUPS + .iter() + .map(|(heading, names)| { + let listed = names + .iter() + .filter_map(|n| rows.iter().find(|(name, _)| name == n).cloned()) + .collect(); + (*heading, listed) + }) + .filter(|(_, listed): &(_, Vec<_>)| !listed.is_empty()) + .collect(); + + let unlisted: Vec<(String, String)> = rows + .into_iter() + .filter(|(name, _)| { + !GROUPS + .iter() + .any(|(_, names)| names.contains(&name.as_str())) + }) + .collect(); + if !unlisted.is_empty() { + groups.push((OTHER, unlisted)); + } + groups +} + +fn description(name: &str) -> Option<&'static str> { + DESCRIPTIONS + .iter() + .find(|(n, _)| *n == name) + .map(|(_, text)| *text) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> Command { + crate::build_app(&crate::spec::effective_services().expect("the bundled specs parse")) + } + + /// The guard behind the "Other" fallback: a command added without a + /// place in [`GROUPS`], or an entry left behind by a rename, fails here + /// rather than drifting into a group nobody chose. + #[test] + fn every_command_has_a_place() { + let rows = rows(&app()); + let visible: Vec<&str> = rows.iter().map(|(name, _)| name.as_str()).collect(); + + let mut problems = Vec::new(); + for name in &visible { + let places = GROUPS + .iter() + .filter(|(_, names)| names.contains(name)) + .count(); + match places { + 1 => {} + 0 => problems.push(format!( + "`{name}` is not in any help group. Add it to GROUPS in src/help_layout.rs." + )), + _ => problems.push(format!( + "`{name}` is in {places} help groups. Keep it in one." + )), + } + } + for (_, names) in GROUPS { + for name in *names { + if !visible.contains(name) { + problems.push(format!( + "`{name}` is in GROUPS but is not a command. Remove it from src/help_layout.rs." + )); + } + } + } + assert!(problems.is_empty(), "{}", problems.join("\n")); + } + + /// An API command's own `about` is its spec's title, which says which + /// API rather than what the command does. + #[test] + fn every_api_command_has_a_description() { + let specs = crate::spec::effective_services().expect("the bundled specs parse"); + let missing: Vec = specs + .iter() + .map(|svc| svc.name.to_string()) + .filter(|name| !DESCRIPTIONS.iter().any(|(n, _)| n == name)) + .map(|name| { + format!( + "`{name}` has no description. Add one to DESCRIPTIONS in src/help_layout.rs." + ) + }) + .collect(); + assert!(missing.is_empty(), "{}", missing.join("\n")); + } + + /// Help and clap's errors use bold and the palettes' fixed colors, never + /// one of the sixteen a terminal theme decides — and without 24-bit + /// color, no color at all. See `output::theme`. + #[test] + fn theme_safe_colors_only() { + use crate::output::theme::{styles_in, Look, LOOKS}; + for &look in LOOKS { + let truecolor = look != Look::Bold; + let app = app().styles(styles_in(look)); + let parsing = for_parsing(apply(app)); + for argv in [ + &["mapbox", "--help"][..], + &["mapbox", "tilesets", "query", "--help"], + &["mapbox", "help", "styles"], + &["mapbox", "--versiomn"], + &["mapbox", "styles", "list", "--outptu", "json"], + &["mapbox", "styles", "get"], + ] { + let err = parsing + .clone() + .try_get_matches_from(argv) + .expect_err("help and mistakes stop the parse"); + let text = err.render().ansi().to_string(); + let problems = unsafe_styles(&text, truecolor); + assert!( + problems.is_empty(), + "{argv:?} ({look:?}) uses {problems:?}:\n{text}" + ); + } + } + } + + /// The SGR parameters in `text` that are neither a reset, bold, nor — + /// with `truecolor` — a 24-bit color from one of the palettes. + fn unsafe_styles(text: &str, truecolor: bool) -> Vec { + let palette: Vec = crate::output::theme::colors() + .iter() + .map(|c| format!("{};{};{}", c.0, c.1, c.2)) + .collect(); + let mut problems = Vec::new(); + for sequence in text.split("\x1b[").skip(1) { + let params: Vec<&str> = sequence + .split('m') + .next() + .unwrap_or_default() + .split(';') + .collect(); + let mut i = 0; + while i < params.len() { + match params[i] { + "" | "0" | "1" | "22" | "39" => i += 1, + "38" if truecolor + && params.get(i + 1) == Some(&"2") + && params.len() >= i + 5 => + { + let rgb = params[i + 2..i + 5].join(";"); + if !palette.contains(&rgb) { + problems.push(format!("38;2;{rgb}")); + } + i += 5; + } + other => { + problems.push(other.to_string()); + i += 1; + } + } + } + } + problems.sort(); + problems.dedup(); + problems + } + + #[test] + fn help_is_recognized_only_where_it_asks_for_help() { + let asks = |args: &[&str]| { + let argv: Vec = std::iter::once("mapbox") + .chain(args.iter().copied()) + .map(Into::into) + .collect(); + help_requested(&argv) + }; + assert!(asks(&["--help"])); + assert!(asks(&["styles", "list", "-h"])); + assert!(asks(&["help", "styles"])); + assert!(asks(&["--no-color", "help"])); + // A style named "help" is a value, not a request: deleting it + // prompts, and the prompt must keep its answer. + assert!(!asks(&["styles", "delete", "help"])); + assert!(!asks(&["tilesets-cli", "--", "--help"])); + assert!(!asks(&["styles", "list"])); + } + + fn shown(text: &str) -> Vec { + pieces(text) + .iter() + .map(|p| format!("{}{}{}", p.prefix, p.text, p.suffix)) + .collect() + } + + /// A code span loses its backticks but keeps the punctuation that + /// touches it, with no space added on either side. + #[test] + fn a_code_span_keeps_the_punctuation_around_it() { + assert_eq!( + shown("Run the Tilesets CLI (`tilesets`, installed separately)"), + [ + "Run", + "the", + "Tilesets", + "CLI", + "(tilesets,", + "installed", + "separately)" + ] + ); + assert_eq!( + shown("Use `mapbox auth login` credentials"), + ["Use", "mapbox auth login", "credentials"] + ); + } + + #[test] + fn a_trailing_env_note_is_one_piece() { + let pieces = pieces("Seconds per request [env: MAPBOX_TIMEOUT]"); + let last = pieces.last().expect("pieces"); + assert_eq!(last.text, "[env: MAPBOX_TIMEOUT]"); + assert!(last.kind == Kind::Note); + } + + #[test] + fn an_unlisted_command_still_shows_under_other() { + let rows = vec![ + ("styles".to_string(), String::new()), + ("brand-new".to_string(), String::new()), + ]; + + let groups = grouped(rows); + let other = groups.iter().find(|(heading, _)| *heading == OTHER); + assert_eq!( + other.map(|(_, rows)| rows + .iter() + .map(|(name, _)| name.as_str()) + .collect::>()), + Some(vec!["brand-new"]) + ); + } +} diff --git a/src/main.rs b/src/main.rs index 952e32b..5c71c53 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ mod deprecation; mod doctor; mod executor; mod generate_skills; +mod help_layout; mod history; mod http; mod link; @@ -53,6 +54,19 @@ use spec::ServiceSpec; /// single source for the Rust half rather than for all three. const REPO_URL: &str = "https://github.com/mapbox/cli"; +/// Opens the top-level help when an agent is driving the CLI. Agents explore +/// by chaining `--help` calls, one command at a time; `--schema` answers the +/// same question for every command in one document. +const AGENT_HELP_HINT: &str = "\ +Agents: `mapbox --schema` describes every command, its arguments and the request it makes +as one JSON document. Use it instead of chaining --help calls."; + +/// Help headings for the global options, which otherwise share one +/// "Options:" list with every command's own. +const AUTH_OPTIONS: &str = "Authentication"; +const OUTPUT_OPTIONS: &str = "Output"; +const BEHAVIOR_OPTIONS: &str = "Behavior"; + /// Rejects a value the spec says is a number, while still yielding a /// `String`. /// @@ -104,8 +118,26 @@ fn looks_like_a_flag(raw: &str) -> bool { /// The spec text is kept whole by `parse_spec` — `--schema` publishes it and /// a cut there lands inside identifiers and decimals. Help has a line to /// work with, so it shortens here, at the only place that wants it. +/// +/// A sentence ends at a full stop followed by white space, or at the end. +/// Cutting at any `.` stopped help inside `{owner}.{tileset}`, `-85.0511` +/// and `e.g.`: "Tileset ID(s) in the format `username". pub fn first_sentence(text: &str) -> &str { - text.split('.').next().unwrap_or(text).trim() + let text = text.trim(); + let end = text.char_indices().find(|&(at, c)| { + c == '.' + && text[at + 1..].starts_with(char::is_whitespace) + && !ends_with_abbreviation(&text[..=at]) + }); + match end { + Some((at, _)) => text[..at].trim_end(), + None => text.strip_suffix('.').unwrap_or(text), + } +} + +/// Full stops that end an abbreviation rather than a sentence. +fn ends_with_abbreviation(text: &str) -> bool { + ["e.g.", "i.e."].iter().any(|abbr| text.ends_with(abbr)) } fn help_text(param: &spec::Parameter) -> String { @@ -400,6 +432,11 @@ fn build_app(specs: &[ServiceSpec]) -> Command { let mut app = Command::new("mapbox") .version(env!("CARGO_PKG_VERSION")) .about("Mapbox API CLI — interact with Mapbox APIs from the command line") + .styles(output::theme::styles()) + .color(output::style::color_choice()) + // clap's own 100-column cap is skipped when `COLUMNS` is set, as many + // shells do; past 100 a description runs too far from its flag. + .max_term_width(100) // See `build_service_command`: without this, `mapbox -o json` is a // successful parse of no command at all, and pairing it with // `arg_required_else_help` made a bare `mapbox` show full help or a @@ -412,6 +449,8 @@ fn build_app(specs: &[ServiceSpec]) -> Command { .short('t') .env(auth::CLAP_TOKEN_ENV) .hide_env_values(true) + .value_name("TOKEN") + .help_heading(AUTH_OPTIONS) .global(true) .help("Mapbox access token") .required(false), @@ -421,6 +460,8 @@ fn build_app(specs: &[ServiceSpec]) -> Command { .long("username") .short('u') .env("MAPBOX_USERNAME") + .value_name("USERNAME") + .help_heading(AUTH_OPTIONS) .global(true) .help("Mapbox username") .required(false), @@ -428,19 +469,19 @@ fn build_app(specs: &[ServiceSpec]) -> Command { .arg( Arg::new("profile") .long("profile") + .value_name("PROFILE") + .help_heading(AUTH_OPTIONS) .global(true) - .help("Named credential profile to use (default: \"default\")") + .help("Credential profile to use (default: \"default\")") .required(false), ) .arg( Arg::new("use-login") .long("use-login") .action(ArgAction::SetTrue) + .help_heading(AUTH_OPTIONS) .global(true) - .help( - "Use credentials from `mapbox auth login`, ignoring any \ - MAPBOX_ACCESS_TOKEN in the environment", - ), + .help("Use `mapbox auth login` credentials, ignoring MAPBOX_ACCESS_TOKEN"), ) .arg( Arg::new("debug") @@ -450,8 +491,9 @@ fn build_app(specs: &[ServiceSpec]) -> Command { // See `--yes` below: without this, `MAPBOX_DEBUG=1` is a usage // error on every command rather than a debug flag. .value_parser(FalseyValueParser::new()) + .help_heading(OUTPUT_OPTIONS) .global(true) - .help("Print request URLs to stderr for debugging"), + .help("Print request URLs to stderr"), ) .arg( Arg::new(confirm::ARG) @@ -472,7 +514,8 @@ fn build_app(specs: &[ServiceSpec]) -> Command { .global(true) // No manual env note: unlike `--output`, this arg really // does declare `.env()`, so clap appends one itself. - .help("Assume yes: never ask before a destructive command"), + .help_heading(BEHAVIOR_OPTIONS) + .help("Don't ask before destructive commands"), ) .arg( Arg::new(output::banner::ARG) @@ -483,8 +526,9 @@ fn build_app(specs: &[ServiceSpec]) -> Command { // Same trap as `--yes`: `MAPBOX_QUIET=1` must not be a usage // error on every command. .value_parser(FalseyValueParser::new()) + .help_heading(OUTPUT_OPTIONS) .global(true) - .help("Don't print the name-and-version banner, or the note after a download, to stderr"), + .help("Hide the version banner and download notes"), ) .arg( Arg::new(http::TIMEOUT_ARG) @@ -504,11 +548,9 @@ fn build_app(specs: &[ServiceSpec]) -> Command { // default. // A value typed here is a different case and stays strict — // it names one flag on one command, and clap says so. + .help_heading(BEHAVIOR_OPTIONS) .global(true) - .help( - "Seconds to wait for one request, connection included. \ - Defaults to 60, or 900 for an upload [env: MAPBOX_TIMEOUT=]", - ), + .help("Seconds per request; 60, or 900 for uploads [env: MAPBOX_TIMEOUT]"), ) .arg( // The arg's id is not `id`: two style operations take a path @@ -517,8 +559,10 @@ fn build_app(specs: &[ServiceSpec]) -> Command { // so the flag can still read as `--id`. Arg::new(output::FILTER_ARG) .long("id") + .value_name("ID") + .help_heading(OUTPUT_OPTIONS) .global(true) - .help("Show only the row with this id, from a command that returns a list"), + .help("Show only the row with this id from a list"), ) .arg( Arg::new(output::ARG) @@ -526,25 +570,43 @@ fn build_app(specs: &[ServiceSpec]) -> Command { .short('o') .value_parser([output::AUTO, output::TEXT, output::JSON]) .default_value(output::AUTO) + .value_name("FORMAT") + // The help names the three values itself, with what `auto` + // means; clap's own list would repeat them. + .hide_possible_values(true) + .help_heading(OUTPUT_OPTIONS) // Deliberately not `.env()`: clap would validate the variable // and turn `export MAPBOX_OUTPUT=` into a usage error on every // command. `Mode::from_matches` reads it leniently instead. .global(true) .help( - "Output format. `auto` reads stdout: a terminal gets text, \ - a pipe or redirect gets JSON [env: MAPBOX_OUTPUT=]", + "text, json, or auto (the default: text in a terminal, JSON when piped) \ + [env: MAPBOX_OUTPUT]", ), ) + .arg( + // Read off argv before the parse, by `style::requested_off`; + // declared here so that it parses, and shows in help. + Arg::new(output::style::NO_COLOR_ARG) + .long(output::style::NO_COLOR_ARG) + .action(ArgAction::SetTrue) + .help_heading(OUTPUT_OPTIONS) + .global(true) + .help("Turn off color, as NO_COLOR does"), + ) .arg( Arg::new(schema::ARG) .long(schema::ARG) .action(ArgAction::SetTrue) + .help_heading(OUTPUT_OPTIONS) .global(true) - .help( - "Describe the command as JSON instead of running it: its arguments, \ - their types, and the request it would make", - ), - ); + .help("Describe the command as JSON instead of running it"), + ) + // clap renders an env-backed arg as `[env: NAME=VALUE]`, so an + // unset variable read as `[env: MAPBOX_USERNAME=]`, and a set one + // put its value in the help. Name the variable only, as `--token` + // always has. + .mut_args(|arg| arg.hide_env_values(true)); // A service every one of whose operations is withheld would be a group // `--help` lists and `subcommand_required(true)` then refuses. None is @@ -770,11 +832,30 @@ fn cli() -> u8 { } }; - let app = build_app(&specs); + // Before the tree is built: it carries clap's color choice, and help is + // rendered during the parse. See `style::requested_off`. + if output::style::requested_off(&raw_argv) { + output::style::turn_off(); + } + if help_layout::help_requested(&raw_argv) { + output::theme::allow_background_query(output::on_a_terminal()); + // Ahead of the parse, which is where clap prints help. + output::banner::show_before_help(&raw_argv); + } + + let mut app = build_app(&specs); + // Here rather than in `build_app`, which tests call directly and which + // should not depend on who happens to be running them. + if agent_detect::detect_agent().is_some() { + app = app.before_help(AGENT_HELP_HINT); + } + // Last, once the tree is complete: the grouped command list and the + // options section are rendered from it. + app = help_layout::apply(app); let argv = tilesets_cli::escape_passthrough_args(&app, raw_argv.clone()); // Parsing consumes the tree, and `--schema` still has to read it // afterwards — so the parse gets the copy and `app` stays whole. - let matches = match app.clone().try_get_matches_from(argv.clone()) { + let matches = match help_layout::for_parsing(app.clone()).try_get_matches_from(argv.clone()) { Ok(matches) => matches, // The ordinary way `--schema` arrives: as a line clap has just // refused, because naming a command is not the same as supplying @@ -936,7 +1017,7 @@ fn clap_tip(rendered: &str) -> Option { /// The subcommand placeholder clap ends a usage line with when one is still /// required. Nothing here sets `subcommand_value_name`, so this is clap's own /// default spelling. -const SUBCOMMAND_PLACEHOLDER: &str = " "; +const SUBCOMMAND_PLACEHOLDER: &str = ""; /// Takes `` back off the usage line of a suggestion that cannot take one. /// @@ -984,10 +1065,18 @@ fn drop_subcommand_from_short_circuit_usage(mut err: clap::Error) -> clap::Error }; // `StyledStr` carries its styling as ANSI inside the string, so // pushing the trimmed rendering back into a new one round-trips the - // styling exactly. The placeholder is plain text at the very end, - // after every styled span, which is what makes it safe to cut. + // styling exactly. The placeholder comes last, wrapped in the help's + // placeholder style: cut it, its style codes, and the space before. let rendered = usage.ansi().to_string(); - let Some(without_subcommand) = rendered.strip_suffix(SUBCOMMAND_PLACEHOLDER) else { + let Some(at) = rendered.rfind(SUBCOMMAND_PLACEHOLDER) else { + return err; + }; + if !is_style_codes(&rendered[at + SUBCOMMAND_PLACEHOLDER.len()..]) { + return err; + } + let Some(without_subcommand) = + without_trailing_style_codes(&rendered[..at]).strip_suffix(' ') + else { return err; }; let mut corrected = StyledStr::new(); @@ -998,6 +1087,25 @@ fn drop_subcommand_from_short_circuit_usage(mut err: clap::Error) -> clap::Error err } +/// Whether `text` is nothing but ANSI style sequences (`ESC [ … m`). +fn is_style_codes(text: &str) -> bool { + without_trailing_style_codes(text).is_empty() +} + +fn without_trailing_style_codes(mut text: &str) -> &str { + while let Some(start) = text.rfind("\x1b[") { + let code = &text[start + 2..]; + let Some(params) = code.strip_suffix('m') else { + break; + }; + if !params.chars().all(|c| c.is_ascii_digit() || c == ';') { + break; + } + text = &text[..start]; + } + text +} + /// The command clap says needs a subcommand, as something to run. /// /// Clap's message names it in quotes — `'mapbox styles' requires a @@ -1775,12 +1883,21 @@ mod tests { /// Help still shows one sentence. This is the whole of what stops the /// description change from rewriting every command's help — `spec.rs` now - /// keeps the paragraph, and this is where it gets cut back. + /// keeps the paragraph, and this is where it gets cut back — and the + /// sentence ends at a full stop, not at a dot inside an id or a number. #[test] fn help_shortens_a_description_to_its_first_sentence() { assert_eq!( first_sentence("Tileset ID in the format `username.id`. Order matters."), - "Tileset ID in the format `username" + "Tileset ID in the format `username.id`" + ); + assert_eq!( + first_sentence("Latitude in degrees (range -85.0511 to 85.0511)."), + "Latitude in degrees (range -85.0511 to 85.0511)" + ); + assert_eq!( + first_sentence("Quality appended to format (e.g. png32). Optional."), + "Quality appended to format (e.g. png32)" ); assert_eq!(first_sentence(" padded. rest"), "padded"); assert_eq!(first_sentence("no full stop here"), "no full stop here"); diff --git a/src/output/banner.rs b/src/output/banner.rs index fba9552..7657f4c 100644 --- a/src/output/banner.rs +++ b/src/output/banner.rs @@ -10,11 +10,12 @@ //! //! In color where [`super::style`] allows it. +use std::ffi::OsString; use std::io::IsTerminal; use clap::ArgMatches; -use super::style::{self, DIM, RESET}; +use super::style::{self, RESET}; use crate::{completion, output}; pub const ARG: &str = "quiet"; @@ -34,8 +35,39 @@ pub fn show(matches: &ArgMatches) { } } -/// Bold, in the accent color. -const NAME: &str = "\x1b[1;94m"; +/// Prints the banner ahead of help — any help, top-level or a command's, as +/// `cf` does: help is the page read most, the version is often what the +/// reader came for, and a command's help without it read as an omission +/// next to the command's own run. +/// +/// clap writes help during the parse, before there are matches for `show` +/// to read, so `-q`/`--quiet` and `MAPBOX_QUIET` are read here the way clap +/// would: the flag anywhere before `--`, the variable as clap's +/// `FalseyValueParser` reads it. +pub fn show_before_help(argv: &[OsString]) { + let terminal = std::io::stderr().is_terminal(); + let quiet = quiet_in(argv, std::env::var(ENV).ok().as_deref()); + if enabled(terminal, quiet, None) { + output::progress(&text(env!("CARGO_PKG_VERSION"), style::enabled(terminal))); + } +} + +fn quiet_in(argv: &[OsString], env: Option<&str>) -> bool { + let (long, short) = (format!("--{ARG}"), format!("-{SHORT}")); + let flag = argv + .iter() + .skip(1) + .filter_map(|arg| arg.to_str()) + .take_while(|arg| *arg != "--") + .any(|arg| arg == long || arg == short); + let variable = env.is_some_and(|value| { + !matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "0" | "n" | "no" | "f" | "false" | "off" + ) + }); + flag || variable +} /// `completion` is excluded because its usual caller is a shell startup /// file, where a banner on every new shell is noise. @@ -46,7 +78,8 @@ fn enabled(stderr_is_terminal: bool, quiet: bool, command: Option<&str>) -> bool fn text(version: &str, color: bool) -> String { let rule = "─".repeat(RULE_WIDTH); if color { - format!("🗺️ {NAME}mapbox{RESET} {DIM}· v{version}{RESET}\n{DIM}{rule}{RESET}") + let (name, muted) = (style::accent(), style::muted()); + format!("🗺️ {name}mapbox{RESET} {muted}· v{version}{RESET}\n{muted}{rule}{RESET}") } else { format!("🗺️ mapbox · v{version}\n{rule}") } @@ -77,10 +110,30 @@ mod tests { assert_eq!(lines.next(), None); } + #[test] + fn quiet_before_help_is_read_as_clap_would() { + let argv = |args: &[&str]| -> Vec { + std::iter::once("mapbox") + .chain(args.iter().copied()) + .map(OsString::from) + .collect() + }; + assert!(quiet_in(&argv(&["-q", "--help"]), None)); + assert!(quiet_in(&argv(&["--help", "--quiet"]), None)); + assert!(quiet_in(&argv(&["--help"]), Some("1"))); + assert!(!quiet_in(&argv(&["--help"]), Some("0"))); + assert!(!quiet_in(&argv(&["--help"]), Some("false"))); + assert!(!quiet_in(&argv(&["--help"]), Some(""))); + assert!(!quiet_in(&argv(&["--help"]), None)); + } + #[test] fn color_changes_the_escapes_and_nothing_else() { let colored = text("1.2.3", true); - assert!(colored.contains(NAME), "{colored:?}"); + assert!( + colored.contains(&format!("{}mapbox", style::accent())), + "{colored:?}" + ); assert!(colored.ends_with(RESET), "{colored:?}"); assert_eq!(style::strip(&colored), text("1.2.3", false)); } diff --git a/src/output/mod.rs b/src/output/mod.rs index ef57d66..facf799 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -24,6 +24,7 @@ pub mod banner; mod error; mod render; pub mod style; +pub mod theme; pub use error::{emit_error, CliError}; pub use render::field_lines; @@ -335,7 +336,7 @@ fn print_tips(tips: &[String]) { /// The lines [`print_tips`] writes, colored or not. The label is bold and the /// advice dimmed, so the tips read as secondary to the result above them. -fn tip_lines(tips: &[String], color: bool) -> Vec { +pub(crate) fn tip_lines(tips: &[String], color: bool) -> Vec { if let [tip] = tips { return vec![format!( "{} {}", @@ -351,6 +352,12 @@ fn tip_lines(tips: &[String], color: bool) -> Vec { .collect() } +/// Whether either output stream is a terminal — something that can be asked +/// for its background color (see `theme::allow_background_query`). +pub fn on_a_terminal() -> bool { + std::io::stdout().is_terminal() || std::io::stderr().is_terminal() +} + /// Whether a result written to stdout may carry color. Only a terminal: /// `-o text > file` must leave a file with no escapes in it. pub fn result_in_color() -> bool { diff --git a/src/output/render.rs b/src/output/render.rs index cbb2764..f38664c 100644 --- a/src/output/render.rs +++ b/src/output/render.rs @@ -299,7 +299,7 @@ fn render_geocoder_list(value: &Value) -> Option { /// The terms under a list, dimmed so the results stay what the eye lands on. fn notice_text(notice: &str, color: bool) -> String { - format!("\n\n{}", style::paint(notice, style::DIM, color)) + format!("\n\n{}", style::paint(notice, &style::muted(), color)) } /// A `FeatureCollection`'s `attribution`, when it carries a usable one. @@ -545,7 +545,7 @@ fn render_feature_list(rows: &[Value]) -> Rendered { /// category, distance, coordinates and attribute names are dimmed, leaving /// the name and address as what a reader scans. fn feature_list_text(rows: &[Value], color: bool) -> String { - let dim = |text: &str| style::paint(text, style::DIM, color); + let dim = |text: &str| style::paint(text, &style::muted(), color); let mut out = String::new(); for (index, row) in rows.iter().enumerate() { if index > 0 { @@ -1088,18 +1088,21 @@ mod tests { } #[test] - fn a_feature_lists_names_are_bold_and_its_details_dim() { + fn a_feature_lists_names_are_bold_and_its_details_muted() { let value = rows( r#"{"type":"FeatureCollection","attribution":"NOTICE: terms","features":[{"type":"Feature","geometry":{"coordinates":[24.941822,60.167507],"type":"Point"},"properties":{"name":"Helsinki","feature_type":"place","full_address":"Helsinki, Uusimaa, Finland"}}]}"#, ); let list = list_rendering(&value, Some("geocoder")).expect("renders"); let colored = styled(&list, true); - let (b, d, r) = (style::BOLD, style::DIM, style::RESET); + let (b, r) = (style::BOLD, style::RESET); + let d = |text: &str| style::paint(text, &style::muted(), true); assert_eq!( colored, format!( - "1. {b}Helsinki{r} {d}(place){r}\n Helsinki, Uusimaa, Finland\n \ - {d}24.941822,60.167507{r}\n\n{d}NOTICE: terms{r}" + "1. {b}Helsinki{r} {}\n Helsinki, Uusimaa, Finland\n {}\n\n{}", + d("(place)"), + d("24.941822,60.167507"), + d("NOTICE: terms"), ) ); assert_eq!(style::strip(&colored), list.text); diff --git a/src/output/style.rs b/src/output/style.rs index f540fbf..33e8e11 100644 --- a/src/output/style.rs +++ b/src/output/style.rs @@ -1,10 +1,13 @@ -//! Terminal color, for the parts of the output a person reads. +//! Terminal color, for the parts of the output a person reads: whether to +//! color, and raw ANSI helpers for the code that writes its own escapes. +//! What the colors are is [`super::theme`]'s business. //! -//! Raw ANSI escapes rather than a crate, following the usual conventions by -//! hand: color only on a stream that is a terminal, and never under -//! `NO_COLOR` or `TERM=dumb`. The palette is the terminal's own (bold, dim, -//! bright blue) rather than fixed RGB values, so it follows the user's theme -//! and reads on a light background as well as a dark one. +//! Whether follows the order `cf` uses, with the command line first: +//! `--no-color` or `NO_COLOR` turns color off whatever else is set, +//! `FORCE_COLOR` (anything but `0`) turns it on even into a pipe, and +//! otherwise a stream is colored when it is a terminal that is not +//! `TERM=dumb`. `color_choice` hands clap the same answer for help and usage +//! errors. //! //! Windows is the one place that needs care: a legacy console prints the //! escapes literally unless virtual-terminal mode is switched on, which takes @@ -15,25 +18,91 @@ //! probing it, so that stdout is only ever touched by the modules //! `tests/source_guards.rs` allows to. +use std::ffi::OsString; +use std::sync::atomic::{AtomicBool, Ordering}; + +use clap::ColorChoice; + +use super::theme; + +/// The global flag that turns color off. +pub const NO_COLOR_ARG: &str = "no-color"; + +static TURNED_OFF: AtomicBool = AtomicBool::new(false); + +/// Whether argv asks for no color, read before clap parses it. +/// +/// Help and usage errors are rendered during the parse, so by the time +/// matches say `--no-color` they have already been printed. This reads the +/// line the way `Mode::early` reads `--output`: up to `--`, past which +/// nothing is ours. An argument meant for `tilesets-cli` that happens to be +/// `--no-color` turns ours off too, which costs only color. +pub fn requested_off(argv: &[OsString]) -> bool { + let flag = format!("--{NO_COLOR_ARG}"); + argv.iter() + .skip(1) + .filter_map(|arg| arg.to_str()) + .take_while(|arg| *arg != "--") + .any(|arg| arg == flag) +} + +/// Turns color off for the rest of the run, as `--no-color` asks. +pub fn turn_off() { + TURNED_OFF.store(true, Ordering::Relaxed); +} + pub const RESET: &str = "\x1b[0m"; pub const BOLD: &str = "\x1b[1m"; -pub const DIM: &str = "\x1b[2m"; -pub const ACCENT: &str = "\x1b[94m"; + +/// The palette's accent, as an escape. Bold where 24-bit color is not known +/// to render. +pub fn accent() -> String { + theme::open(theme::accent()) +} + +/// The palette's muted color, as an escape. Empty where 24-bit color is not +/// known to render: plain text, not a theme's `dim`. +pub fn muted() -> String { + theme::open(theme::muted()) +} /// Whether a stream should be written in color. pub fn enabled(stream_is_terminal: bool) -> bool { - stream_is_terminal - && allowed( - env_value("NO_COLOR").is_some(), - env_value("TERM").as_deref(), - !cfg!(windows) - || env_value("WT_SESSION").is_some() - || env_value("TERM_PROGRAM").is_some(), - ) + decide(no_color(), env_value("FORCE_COLOR").as_deref()).unwrap_or_else(|| { + stream_is_terminal && env_value("TERM").as_deref() != Some("dumb") && console_renders_ansi() + }) +} + +/// The same decision for clap, which checks for a terminal itself. +pub fn color_choice() -> ColorChoice { + match decide(no_color(), env_value("FORCE_COLOR").as_deref()) { + Some(true) => ColorChoice::Always, + Some(false) => ColorChoice::Never, + None if env_value("TERM").as_deref() == Some("dumb") => ColorChoice::Never, + None => ColorChoice::Auto, + } +} + +/// `--no-color` or `NO_COLOR`. +fn no_color() -> bool { + TURNED_OFF.load(Ordering::Relaxed) || env_value("NO_COLOR").is_some() +} + +/// What the environment settles before the stream is looked at, if anything. +fn decide(no_color: bool, force_color: Option<&str>) -> Option { + if no_color { + Some(false) + } else { + force_color.map(|value| value != "0") + } } -fn allowed(no_color: bool, term: Option<&str>, console_renders_ansi: bool) -> bool { - !no_color && term != Some("dumb") && console_renders_ansi +/// A legacy Windows console prints escapes literally unless +/// virtual-terminal mode is on, and switching it on takes an API call this +/// crate has no `unsafe` for — so color there is kept to terminals known to +/// have it on already. +fn console_renders_ansi() -> bool { + !cfg!(windows) || env_value("WT_SESSION").is_some() || env_value("TERM_PROGRAM").is_some() } /// A variable's value, treating an empty one as unset — `NO_COLOR=` is how a @@ -42,22 +111,23 @@ fn env_value(name: &str) -> Option { std::env::var(name).ok().filter(|value| !value.is_empty()) } -/// `text` in `style`, or unchanged when color is off. +/// `text` in `style`, or unchanged when color is off or `style` is empty. pub fn paint(text: &str, style: &str, on: bool) -> String { - if on { + if on && !style.is_empty() { format!("{style}{text}{RESET}") } else { text.to_string() } } -/// Dims prose while keeping its `code spans` in the accent color, so the part -/// a reader copies stands out from the advice around it. The backticks stay: +/// Mutes prose while keeping its `code spans` in the accent, so the part a +/// reader copies stands out from the advice around it. The backticks stay: /// the text must mean the same thing with the color stripped. pub fn dim_prose(text: &str, on: bool) -> String { if !on { return text.to_string(); } + let (accent, muted) = (accent(), muted()); let parts: Vec<&str> = text.split('`').collect(); let mut out = String::new(); for (index, part) in parts.iter().enumerate() { @@ -67,11 +137,11 @@ pub fn dim_prose(text: &str, on: bool) -> String { // the text. let unpaired = index == parts.len() - 1 && index % 2 == 1; if unpaired { - out.push_str(&format!("{DIM}`{part}{RESET}")); + out.push_str(&paint(&format!("`{part}"), &muted, true)); } else if index % 2 == 1 { - out.push_str(&format!("{ACCENT}`{part}`{RESET}")); + out.push_str(&paint(&format!("`{part}`"), &accent, true)); } else if !part.is_empty() { - out.push_str(&format!("{DIM}{part}{RESET}")); + out.push_str(&paint(part, &muted, true)); } } out @@ -96,17 +166,30 @@ pub fn strip(text: &str) -> String { mod tests { use super::*; + /// `NO_COLOR` wins over everything; `FORCE_COLOR` decides next, with + /// `0` meaning off; with neither, the stream decides. #[test] - fn no_color_a_dumb_terminal_or_a_legacy_console_get_plain_text() { - assert!(allowed(false, Some("xterm-256color"), true)); - assert!(!allowed(true, Some("xterm-256color"), true)); - assert!(!allowed(false, Some("dumb"), true)); - assert!(!allowed(false, None, false)); + fn no_color_then_force_color_then_the_stream() { + assert_eq!(decide(true, Some("1")), Some(false)); + assert_eq!(decide(false, Some("1")), Some(true)); + assert_eq!(decide(false, Some("3")), Some(true)); + assert_eq!(decide(false, Some("0")), Some(false)); + assert_eq!(decide(false, None), None); } #[test] - fn a_stream_that_is_not_a_terminal_is_never_colored() { - assert!(!enabled(false)); + fn no_color_is_read_up_to_the_end_of_options() { + let argv = |args: &[&str]| -> Vec { + std::iter::once("mapbox") + .chain(args.iter().copied()) + .map(OsString::from) + .collect() + }; + assert!(requested_off(&argv(&["--no-color", "--help"]))); + assert!(requested_off(&argv(&["styles", "list", "--no-color"]))); + assert!(!requested_off(&argv(&["styles", "list"]))); + assert!(!requested_off(&argv(&["tilesets-cli", "--", "--no-color"]))); + assert!(!requested_off(&argv(&["--no-colors"]))); } #[test] @@ -120,7 +203,7 @@ mod tests { let tip = "Values are shortened; `-o json` prints each row whole."; let colored = dim_prose(tip, true); assert!( - colored.contains(&format!("{ACCENT}`-o json`{RESET}")), + colored.contains(&format!("{}`-o json`{RESET}", accent())), "{colored:?}" ); assert_eq!(strip(&colored), tip); diff --git a/src/output/theme.rs b/src/output/theme.rs new file mode 100644 index 0000000..0c947f8 --- /dev/null +++ b/src/output/theme.rs @@ -0,0 +1,343 @@ +//! The palette: every color this CLI puts on a terminal, and when. +//! +//! A terminal's sixteen ANSI colors are whatever its theme says they are, +//! so none of them is safe on every background. Measured against ten common +//! themes, ANSI cyan fell to 2.1:1 on iTerm2's light background and dimmed +//! text to 1.9:1 on Solarized Light — below the 3:1 bold text needs. +//! +//! So the palette is fixed RGB, the approach Cloudflare's `cf` CLI takes — +//! three of them, for the background the colors land on. Where the terminal +//! answers an OSC 11 query for its background color, colors are chosen for +//! a dark or a light one and clear 4.5:1 on the common themes of that kind. +//! Where it does not (tmux, a slow link, a terminal without the query), the +//! fallback sits at mid luminance, the band where a color reads on black +//! and on white alike: about 5.2:1 and 4.1:1 for text, no worse than 3.5:1 +//! on any theme measured. In every palette the accent is only ever bold, so +//! it needs 3:1 rather than the 4:1 text does, and spends the difference on +//! being bright. `every_color_clears_its_floor` holds each color to that. +//! +//! RGB needs a terminal that renders it. Where one is not known to, there is +//! no palette at all, only what any terminal shows the same way: the accent +//! and the error color become bold, and muted text becomes plain. +//! +//! Whether to color at all is [`super::style::enabled`]'s answer, and this +//! module only says what with. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use std::time::Duration; + +use clap::builder::styling::{RgbColor, Style, Styles}; + +/// What the terminal draws on, as far as it says. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Background { + Dark, + Light, + Unknown, +} + +/// How color is rendered: the palette for a background, or bold alone where +/// 24-bit color is not known to render. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Look { + Rgb(Background), + Bold, +} + +/// The three palettes share one accent hue, a teal (`#60B8C5` on a dark +/// background), at the lightness each background needs. +struct Palette { + /// Headings, the banner's name, the names in a tip. Only ever bold. + accent: RgbColor, + /// Notes, hints, the banner's version — set back from the result + /// without the faintness `dim` has on some themes. + muted: RgbColor, + error: RgbColor, +} + +/// On a dark background: the accent at full strength, 6.2:1 or better on +/// the dark themes measured, and text colors at 4.75:1 or better. +const DARK: Palette = Palette { + accent: RgbColor(0x60, 0xb8, 0xc5), + muted: RgbColor(0x95, 0x95, 0x95), + error: RgbColor(0xe8, 0x71, 0x71), +}; + +/// On a light background: the accent's hue darkened to 0.14 luminance, +/// 5.1:1 or better on the light themes measured, and text colors at 4.8:1 +/// or better. +const LIGHT: Palette = Palette { + accent: RgbColor(0x2d, 0x72, 0x7c), + muted: RgbColor(0x6d, 0x6d, 0x6d), + error: RgbColor(0xbe, 0x1f, 0x1f), +}; + +/// Background unknown: mid luminance. The accent's hue at 0.30, the +/// brightest still at 3:1 on white — 6.9:1 on black, and 2.8:1 on +/// Solarized Light, the one theme measured it falls short on, chosen over a +/// dimmer accent everywhere else. Text colors sit at 0.21. +const EITHER: Palette = Palette { + accent: RgbColor(0x40, 0xa1, 0xb0), + muted: RgbColor(0x7f, 0x7f, 0x7f), + error: RgbColor(0xe1, 0x46, 0x46), +}; + +fn palette(background: Background) -> &'static Palette { + match background { + Background::Dark => &DARK, + Background::Light => &LIGHT, + Background::Unknown => &EITHER, + } +} + +/// How long to wait for the terminal to report its background. A terminal +/// without the query answers the one sent after it straight away, so this +/// only bounds a slow link; running out costs the fallback palette, not the +/// command. +const QUERY_TIMEOUT: Duration = Duration::from_millis(100); + +/// The look for this run, settled once. +pub fn current() -> Look { + static LOOK: OnceLock = OnceLock::new(); + *LOOK.get_or_init(|| { + if truecolor() { + Look::Rgb(background()) + } else { + Look::Bold + } + }) +} + +static QUERY_ALLOWED: AtomicBool = AtomicBool::new(false); + +/// Lets this run ask the terminal for its background, given whether there +/// is a terminal to ask — `output::on_a_terminal`, since this module does +/// not touch the streams itself. Only for help: see [`background`]. +pub fn allow_background_query(on_a_terminal: bool) { + QUERY_ALLOWED.store(on_a_terminal, Ordering::Relaxed); +} + +/// Asks the terminal for its background color. +/// +/// Only for a run that shows help and then exits. The answer arrives on the +/// terminal's input, and whatever else is waiting there is read with it: a +/// command that goes on to ask "Delete? [y/N]" lost the answer typed ahead +/// to the query — `non_interactive`'s prompt test hung on exactly that. Help +/// reads nothing after, so there is nothing to lose; every other run uses +/// the `Unknown` palette. +/// +/// Also only when there is a terminal to ask and color will be shown on it, +/// so a pipe, `NO_COLOR` or `--no-color` never sends the query. +fn background() -> Background { + if !QUERY_ALLOWED.load(Ordering::Relaxed) || !super::style::enabled(true) { + return Background::Unknown; + } + let mut options = terminal_colorsaurus::QueryOptions::default(); + options.timeout = QUERY_TIMEOUT; + // The background alone, not `theme_mode`, which asks for the foreground + // too: the timeout is per read, and on a terminal that never answers two + // queries waited half a second. + match terminal_colorsaurus::background_color(options) { + Ok(color) if color.perceived_lightness() < 0.5 => Background::Dark, + Ok(_) => Background::Light, + Err(_) => Background::Unknown, + } +} + +/// Whether the terminal is known to render 24-bit color. +/// +/// The same signals `cf` reads. Apple's Terminal is left out on purpose: it +/// renders 256 colors, and maps 24-bit ones to the nearest of those. +pub fn truecolor() -> bool { + static TRUECOLOR: std::sync::OnceLock = std::sync::OnceLock::new(); + *TRUECOLOR.get_or_init(|| { + let var = |name| std::env::var(name).unwrap_or_default(); + supports_truecolor( + &var("COLORTERM"), + &var("TERM"), + &var("TERM_PROGRAM"), + std::env::var_os("WT_SESSION").is_some(), + ) + }) +} + +fn supports_truecolor( + colorterm: &str, + term: &str, + term_program: &str, + windows_terminal: bool, +) -> bool { + matches!(colorterm, "truecolor" | "24bit") + || matches!(term, "xterm-kitty" | "xterm-ghostty" | "wezterm") + || matches!(term_program, "iTerm.app" | "vscode" | "WezTerm" | "ghostty") + || windows_terminal +} + +/// The accent: headings, the banner's name, and the names in a tip a reader +/// copies. Always bold. +pub fn accent() -> Style { + accent_in(current()) +} + +/// Secondary text. +pub fn muted() -> Style { + muted_in(current()) +} + +fn accent_in(look: Look) -> Style { + colored(look, |p| p.accent).bold() +} + +/// Bold like every other color here: a colored run of thin text reads as +/// fainter than the same color bold, and the palette is set to be read. +fn muted_in(look: Look) -> Style { + match look { + Look::Rgb(_) => colored(look, |p| p.muted).bold(), + Look::Bold => Style::new(), + } +} + +fn colored(look: Look, pick: fn(&Palette) -> RgbColor) -> Style { + match look { + Look::Rgb(background) => Style::new().fg_color(Some(pick(palette(background)).into())), + Look::Bold => Style::new(), + } +} + +/// clap's styles for help and usage errors. +/// +/// Headings in the accent; names to type, values to fill in and clap's +/// suggestions bold in the terminal's own foreground — the color every +/// theme makes most readable, for the part a reader has to get exactly +/// right; notes muted. Values are not underlined: clap gives the space +/// before a value the value's style, which bold hides and an underline +/// shows as a stray rule. +pub fn styles() -> Styles { + styles_in(current()) +} + +pub fn styles_in(look: Look) -> Styles { + let bold = Style::new().bold(); + Styles::plain() + .header(accent_in(look)) + .usage(accent_in(look)) + .literal(bold) + .placeholder(bold) + .valid(bold) + .invalid(bold) + .error(colored(look, |p| p.error).bold()) + .context(muted_in(look)) + // `[possible values: on, off]`: the note muted, the values in it bold + // like every other thing to type. + .context_value(bold) +} + +/// The escape that opens `style`, for the code that writes raw ANSI. +pub fn open(style: Style) -> String { + style.render().to_string() +} + +/// Every look there is, for tests that render each. +#[cfg(test)] +pub(crate) const LOOKS: &[Look] = &[ + Look::Rgb(Background::Dark), + Look::Rgb(Background::Light), + Look::Rgb(Background::Unknown), + Look::Bold, +]; + +/// Every color in every palette. +#[cfg(test)] +pub(crate) fn colors() -> Vec { + [&DARK, &LIGHT, &EITHER] + .iter() + .flat_map(|p| [p.accent, p.muted, p.error]) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn luminance(RgbColor(r, g, b): RgbColor) -> f64 { + let channel = |c: u8| { + let c = f64::from(c) / 255.0; + if c <= 0.039_28 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) + } + + fn contrast(a: f64, b: f64) -> f64 { + (a.max(b) + 0.05) / (a.min(b) + 0.05) + } + + fn hex(code: u32) -> f64 { + luminance(RgbColor((code >> 16) as u8, (code >> 8) as u8, code as u8)) + } + + /// The palette's one rule: each color clears its floor on every + /// background it may land on — 3:1 for the accent, which is only ever + /// bold, and 4.5:1 (4:1 for the fallback, which has to serve both kinds) + /// for the colors used on plain text. The backgrounds are the common + /// themes measured: plain black and white, VS Code, Windows Terminal, + /// Solarized and Dracula. + #[test] + fn every_color_clears_its_floor() { + let dark = [0x000000, 0x1c1c1c, 0x1e1e1e, 0x0c0c0c, 0x002b36, 0x282a36].map(hex); + let light = [0xffffff, 0xfdf6e3, 0xf6f8fa].map(hex); + let either: Vec = [0.0, 1.0].to_vec(); + for (name, palette, backgrounds, text_floor) in [ + ("dark", &DARK, dark.to_vec(), 4.5), + ("light", &LIGHT, light.to_vec(), 4.5), + ("fallback", &EITHER, either, 4.0), + ] { + for (role, color, floor) in [ + ("accent", palette.accent, 3.0), + ("muted", palette.muted, text_floor), + ("error", palette.error, text_floor), + ] { + for &background in &backgrounds { + let ratio = contrast(luminance(color), background); + assert!( + ratio >= floor, + "{name} {role} {color:?} is {ratio:.2}:1 on a background of \ + luminance {background:.3}, under {floor}:1" + ); + } + } + } + } + + #[test] + fn the_terminals_cf_trusts_with_24_bit_color() { + assert!(supports_truecolor("truecolor", "", "", false)); + assert!(supports_truecolor("24bit", "", "", false)); + assert!(supports_truecolor("", "xterm-ghostty", "", false)); + assert!(supports_truecolor("", "xterm-256color", "iTerm.app", false)); + assert!(supports_truecolor("", "", "", true)); + assert!(!supports_truecolor( + "", + "xterm-256color", + "Apple_Terminal", + false + )); + assert!(!supports_truecolor("", "xterm-256color", "", false)); + } + + /// Without 24-bit color the accent is bold and muted text plain, never a + /// theme's ANSI color. + #[test] + fn the_fallback_is_bold_and_plain() { + assert_eq!(accent_in(Look::Bold), Style::new().bold()); + assert_eq!(muted_in(Look::Bold), Style::new()); + assert_eq!( + accent_in(Look::Rgb(Background::Light)), + Style::new().fg_color(Some(LIGHT.accent.into())).bold() + ); + } +} diff --git a/src/tilesets_cli.rs b/src/tilesets_cli.rs index 8d0caa3..5123eed 100644 --- a/src/tilesets_cli.rs +++ b/src/tilesets_cli.rs @@ -178,7 +178,7 @@ pub fn warn_yes_ignored(matches: &ArgMatches) { pub fn command() -> Command { Command::new(COMMAND) - .about("Proxy commands to the Mapbox Tilesets CLI (`tilesets`, installed separately)") + .about("Run the Mapbox Tilesets CLI (`tilesets`, installed separately)") .long_about(format!( "Forward arguments to the Mapbox Tilesets CLI.\n\n\ Everything after `{COMMAND}` is passed through to the `tilesets` binary \ @@ -313,7 +313,7 @@ fn short_cluster_takes_next(app: &Command, cluster: &str) -> bool { /// `the_misplaced_globals_list_matches_the_app`: the spellings so a new /// global forces a decision about forwarding it, and the value flag so the /// hint below stays a command line that actually works. -const MAPBOX_ONLY_GLOBALS: [(&str, bool); 13] = [ +const MAPBOX_ONLY_GLOBALS: [(&str, bool); 14] = [ ("--use-login", false), ("--schema", false), ("--profile", true), @@ -336,6 +336,9 @@ const MAPBOX_ONLY_GLOBALS: [(&str, bool); 13] = [ // warning in `warn_yes_ignored` for the other half of the mistake. ("--yes", false), ("-y", false), + // The child colors its own output its own way; ours goes before the + // subcommand, where it covers the warnings this proxy prints. + ("--no-color", false), // Only the short spelling: `upload-source` and `upload-raster-source` // take a `--quiet` of their own, and forwarding it is how a caller hides // their progress bar. diff --git a/tests/output_contract.rs b/tests/output_contract.rs index 62b5048..69a0aa9 100644 --- a/tests/output_contract.rs +++ b/tests/output_contract.rs @@ -363,6 +363,411 @@ fn help_is_never_wrapped() { assert!(text.contains("--output"), "--output should be documented"); } +/// Top-level help is where an agent that only installed the CLI looks for +/// the rest of Mapbox's agent tooling, so it must list each link — on stdout, +/// with the help — and subcommand help must not repeat them. +#[test] +fn top_level_help_links_to_mapbox_resources() { + const LINKS: &[&str] = &[ + "https://docs.mapbox.com/cli/", + "https://cli.mapbox.com/agent-setup/prompt.md", + "https://github.com/mapbox/mapbox-agent-skills", + "https://github.com/mapbox/mcp-server", + "https://docs.mapbox.com/api/overview/", + ]; + + for args in [&["--help"][..], &["-h"][..], &["help"][..]] { + let out = run(args); + assert!(out.status.success(), "{args:?} exited non-zero"); + let text = stdout(&out); + // The heading carries clap's header style, which must not leak + // into a pipe as raw escape codes. + assert!( + !text.contains('\x1b'), + "{args:?} has escape codes: {text:?}" + ); + for link in LINKS { + assert!(text.contains(link), "{args:?} is missing {link}: {text}"); + } + } + + for args in [&["styles", "--help"][..], &["help", "styles"][..]] { + let text = stdout(&run(args)); + assert!( + !text.contains("Learn more:"), + "{args:?} should not carry the top-level links: {text}" + ); + } +} + +/// Help used to run each option onto one line however long, so an 80-column +/// terminal folded descriptions back to column 0. clap now wraps to the +/// terminal, or to 100 columns when there is none, as here. +#[test] +fn help_lines_fit_the_wrap_width() { + let text = stdout(&run(&["--help"])); + for line in text.lines() { + assert!( + line.chars().count() <= 100, + "line over 100 columns: {line:?}" + ); + } +} + +/// An env-backed option names its variable and nothing more: an unset one +/// used to read `[env: MAPBOX_USERNAME=]`, and a set one printed its value. +#[test] +fn help_names_env_variables_without_their_values() { + let out = command() + .env("MAPBOX_USERNAME", "someone-in-particular") + .arg("--help") + .output() + .expect("run mapbox"); + let text = stdout(&out); + + assert!(text.contains("[env: MAPBOX_USERNAME]"), "{text}"); + assert!(!text.contains("someone-in-particular"), "{text}"); + assert!(!text.contains("=]"), "an env note still shows `=`: {text}"); +} + +const AGENT_HINT: &str = "Agents: `mapbox --schema`"; + +/// An agent reading top-level help is pointed at `--schema` rather than left +/// to chain `--help` calls. Only there: subcommand help stays as it was. +#[test] +fn an_agent_is_pointed_at_the_schema() { + let out = command() + .env("CLAUDECODE", "1") + .arg("--help") + .output() + .expect("run mapbox"); + assert!(out.status.success()); + assert!(stdout(&out).contains(AGENT_HINT), "{}", stdout(&out)); + + let sub = command() + .env("CLAUDECODE", "1") + .args(["styles", "--help"]) + .output() + .expect("run mapbox"); + assert!(!stdout(&sub).contains(AGENT_HINT), "{}", stdout(&sub)); +} + +/// A person gets the help they always did. The environment is cleared rather +/// than having agent variables removed one by one: the suite itself often +/// runs under an agent, and `agent_detect` knows more variables than this +/// file should have to list. +#[test] +fn without_an_agent_the_help_has_no_hint() { + let home = sandbox_home(); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_mapbox")); + cmd.env_clear() + .env("HOME", &home) + .env("MAPBOX_CONFIG_DIR", home.join(".mapbox")); + // Windows processes expect this one; nothing reads it as an agent. + if let Some(root) = std::env::var_os("SystemRoot") { + cmd.env("SystemRoot", root); + } + let out = cmd.arg("--help").output().expect("run mapbox"); + + assert!(out.status.success()); + assert!(!stdout(&out).contains(AGENT_HINT), "{}", stdout(&out)); +} + +/// Top-level help lists commands under named groups rather than clap's one +/// "Commands:" list, and the global options under their own headings. The +/// table behind the groups is checked against the command tree in +/// `src/help_layout.rs`; this checks what a reader actually sees. +#[test] +fn top_level_help_groups_commands_and_options() { + let text = stdout(&run(&["--help"])); + + let headings = [ + "Maps and data:", + "Search:", + "Account:", + "Coding agents:", + "CLI:", + "Authentication:", + "Output:", + "Behavior:", + "Learn more:", + ]; + let mut last = 0; + for heading in headings { + let at = text + .find(&format!("\n{heading}\n")) + .unwrap_or_else(|| panic!("no {heading:?} heading: {text}")); + assert!(at > last, "{heading:?} is out of order: {text}"); + last = at; + } + assert!( + !text.contains("\nCommands:\n"), + "clap's flat list is back: {text}" + ); + assert!( + !text.contains("Other:"), + "a command fell through to Other: {text}" + ); + assert!( + text.lines().any(|line| line.starts_with(" accounts ") + && line.ends_with(" List access tokens and their scopes")), + "an API command should describe what it does, not its spec title: {text}" + ); + // The commands that install the linked skills and MCP server are listed + // with the rest, which is why the links themselves don't repeat them. + for command in ["agent-skills", "mcp"] { + assert!( + text.lines() + .any(|line| line.starts_with(&format!(" {command} "))), + "`{command}` is not listed: {text}" + ); + } +} + +/// Command sections share one description column and option sections +/// another, wrapped lines continue on their column, and nothing passes the +/// terminal's width — the page is rendered here rather than by clap, so none +/// of that comes for free. +#[test] +fn top_level_help_aligns_and_wraps_to_the_terminal() { + const OPTION_SECTIONS: &[&str] = &["Authentication:", "Output:", "Behavior:"]; + + let out = command() + .env("COLUMNS", "80") + .env_remove("CLAUDECODE") + .arg("--help") + .output() + .expect("run mapbox"); + let text = stdout(&out); + let page = &text[text.find("Usage:").expect("a usage line")..]; + + // Where a row's description starts: past its name and the gap after it. + let column = |line: &str| { + let indent = line.len() - line.trim_start().len(); + let gap = indent + line.trim_start().find(" ")?; + Some(gap + line[gap..].len() - line[gap..].trim_start().len()) + }; + let row_column = |prefix: &str| { + page.lines() + .find(|line| line.starts_with(prefix)) + .and_then(column) + .unwrap_or_else(|| panic!("no {prefix:?} row: {page}")) + }; + let names = row_column(" styles "); + let options = row_column(" -t, --token "); + assert!( + names < options, + "commands should not wait on the longest option" + ); + + let mut expected = names; + for line in page.lines() { + assert!(line.chars().count() <= 80, "over 80 columns: {line:?}"); + if !line.starts_with(' ') && line.ends_with(':') { + expected = if OPTION_SECTIONS.contains(&line) { + options + } else { + names + }; + } else if line.starts_with(" ") && !line.trim().is_empty() { + // A wrapped line starts on the column; a row reaches it after + // its name. + let indent = line.len() - line.trim_start().len(); + let at = if indent == expected { + Some(indent) + } else { + column(line) + }; + assert_eq!(at, Some(expected), "off its section's column: {line:?}"); + } + } + assert!( + !page.contains('`'), + "code spans should show without backticks: {page}" + ); +} + +/// clap's long layout — every description on a line of its own, a blank +/// line between options, notes as paragraphs — made a subcommand's `--help` +/// mostly white space, and the eleven global options repeated on every page +/// outweighed the command's own. Now: the compact layout, the command's long +/// description at the top, and one line standing in for the global options, +/// whichever way help is asked for. +#[test] +fn subcommand_help_is_compact_and_about_the_command() { + for args in [ + &["tilesets", "query", "--help"][..], + &["help", "tilesets", "query"][..], + ] { + let out = run(args); + assert!(out.status.success(), "{args:?} failed"); + let text = stdout(&out); + + assert!( + text.starts_with("Query one or more tilesets at a coordinate"), + "{args:?} lost its long description: {text}" + ); + assert!( + text.lines() + .any(|line| line.contains("--geometry") && line.contains("[possible values:")), + "{args:?} should keep notes on the option's line: {text}" + ); + assert!( + !text.contains("--token ") && !text.contains("Authentication:"), + "{args:?} spells out the global options again: {text}" + ); + assert!( + text.contains("Run mapbox --help for the global options"), + "{args:?} should still point at the global options: {text}" + ); + assert!(!text.contains('`'), "{args:?} shows backticks: {text}"); + } +} + +/// Hidden from a subcommand's help, a global option still parses there and +/// is still offered for a typo; and the top-level usage line still says the +/// command takes options. +#[test] +fn hidden_global_options_still_work_and_are_suggested() { + let typo = run(&["-o", "text", "styles", "list", "--outptu", "json"]); + assert!( + stderr(&typo).contains("a similar argument exists: '--output'"), + "{}", + stderr(&typo) + ); + + let dry = run(&[ + "styles", + "delete", + "some-style", + "--username", + "someone", + "--dry-run", + ]); + assert!( + !stderr(&dry).contains("unexpected argument"), + "a global option was refused on a subcommand: {}", + stderr(&dry) + ); + + let help = stdout(&run(&["--help"])); + assert!(help.contains("Usage: mapbox [OPTIONS] "), "{help}"); +} + +/// The compact help is made on the copy of the tree that parses; `--schema` +/// still publishes the spec's own text, markup included. +#[test] +fn the_schema_keeps_the_text_help_simplifies() { + let out = run(&["tilesets", "query", "--schema"]); + assert!(out.status.success()); + assert!( + stdout(&out).contains("`{owner}.{tileset}`"), + "--schema should keep the spec's wording: {}", + stdout(&out) + ); +} + +/// Color follows the order `cf` uses: `NO_COLOR` wins, then `FORCE_COLOR` +/// (`0` meaning off), then whether the stream is a terminal — which here it +/// never is. The palette's 24-bit colors appear only where the terminal is +/// known to render them; elsewhere styling is bold alone. +#[test] +fn color_follows_no_color_then_force_color() { + let help = |vars: &[(&str, &str)]| { + let mut cmd = command(); + for var in [ + "NO_COLOR", + "FORCE_COLOR", + "COLORTERM", + "TERM_PROGRAM", + "WT_SESSION", + "TERM", + ] { + cmd.env_remove(var); + } + cmd.envs(vars.iter().copied()); + stdout(&cmd.arg("--help").output().expect("run mapbox")) + }; + const MUTED: &str = "\x1b[38;2;127;127;127m"; + + assert!(!help(&[]).contains('\x1b'), "a pipe gets plain text"); + + let forced = help(&[("FORCE_COLOR", "1")]); + assert!( + forced.contains("\x1b[1m"), + "FORCE_COLOR should style a pipe: {forced:?}" + ); + assert!( + !forced.contains("\x1b[38;2;"), + "no 24-bit color where the terminal is not known to render it: {forced:?}" + ); + + // Notes are the one color help uses; headings take the theme's own. + let truecolor = help(&[("FORCE_COLOR", "1"), ("COLORTERM", "truecolor")]); + assert!(truecolor.contains(MUTED), "{truecolor:?}"); + + let both = help(&[("FORCE_COLOR", "1"), ("NO_COLOR", "1")]); + assert!(!both.contains('\x1b'), "NO_COLOR wins: {both:?}"); + assert!(!help(&[("FORCE_COLOR", "0")]).contains('\x1b')); +} + +/// `--no-color` does what `NO_COLOR` does, wherever it is on the line, and +/// wins over `FORCE_COLOR` — including in help, which clap renders before +/// the matches that would say so exist. +#[test] +fn the_no_color_flag_turns_color_off() { + let run_with = |args: &[&str]| { + command() + .env("FORCE_COLOR", "1") + .env("COLORTERM", "truecolor") + .env_remove("NO_COLOR") + .args(args) + .output() + .expect("run mapbox") + }; + + for args in [ + &["--no-color", "--help"][..], + &["tilesets", "query", "--help", "--no-color"], + ] { + let text = stdout(&run_with(args)); + assert!(!text.contains('\x1b'), "{args:?} kept color: {text:?}"); + } + + let typo = run_with(&["-o", "text", "--no-color", "--versiomn"]); + assert!(!stderr(&typo).contains('\x1b'), "{:?}", stderr(&typo)); + + let parsed = run_with(&[ + "--no-color", + "styles", + "delete", + "some-style", + "--username", + "someone", + "--dry-run", + ]); + assert!( + !stderr(&parsed).contains("unexpected argument"), + "{}", + stderr(&parsed) + ); +} + +/// Forced color is for a person reading a pipe through a pager; a JSON error +/// is for a program, and must not carry escapes inside its strings. +#[test] +fn forced_color_never_reaches_json() { + let out = command() + .env("FORCE_COLOR", "1") + .env("COLORTERM", "truecolor") + .args(["-o", "json", "--versiomn"]) + .output() + .expect("run mapbox"); + let err = stderr(&out); + assert!(!err.contains('\x1b'), "{err:?}"); + assert_eq!(json(&err)["code"], "usage"); +} + /// An operation that can never succeed is not in the command surface, so it /// answers exactly as a mistyped name does — same code, same shape, same /// exit. Anything else would tell a caller which scopes exist while still