Skip to main content

vta_cli_common/
render.rs

1use ratatui::{
2    buffer::{Buffer, CellDiffOption},
3    layout::Rect,
4    style::{Color, Modifier},
5    widgets::Widget,
6};
7use std::sync::OnceLock;
8use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
9
10// ── Bin-name registration ───────────────────────────────────────────
11//
12// pnm-cli and cnm-cli both consume this crate's shared command
13// handlers. When one of those handlers needs to point the operator at a
14// follow-up command (e.g. context-create → "did you mean to run X
15// instead?"), it must use the binary the operator actually invoked,
16// not a hard-coded `pnm`. Each CLI binary calls `set_bin_name("pnm")`
17// or `set_bin_name("cnm")` at startup; handlers read via `bin_name()`
18// and fall back to "vta" if neither was registered (the offline `vta
19// bootstrap …` path also calls into shared modules).
20
21static BIN_NAME: OnceLock<&'static str> = OnceLock::new();
22
23/// Register the binary name used in operator-facing hints. Call once at
24/// CLI startup. Only the first call sticks; later calls are ignored so
25/// that nested invocations (e.g. unit tests) don't clobber it.
26pub fn set_bin_name(name: &'static str) {
27    let _ = BIN_NAME.set(name);
28}
29
30/// The binary name registered via [`set_bin_name`]. Defaults to `"vta"`
31/// (the offline binary's name) when nothing has been registered, so
32/// shared handlers still produce a syntactically valid command string.
33pub fn bin_name() -> &'static str {
34    BIN_NAME.get().copied().unwrap_or("vta")
35}
36
37// ── Full-display toggle ─────────────────────────────────────────────
38//
39// CLI global `--full-display` flag. When enabled, list commands emit
40// every identifier in full (no ratatui-Table truncation) as a sequence
41// of key-value blocks. Default rendering stays as the compact table
42// for a readable overview; full display is the escape hatch for
43// copying complete DIDs, key ids, template names, etc.
44
45static FULL_DISPLAY: AtomicBool = AtomicBool::new(false);
46
47/// Enable or disable full-display output. Called once at CLI startup
48/// from the global flag.
49pub fn set_full_display(enabled: bool) {
50    FULL_DISPLAY.store(enabled, Ordering::Relaxed);
51}
52
53/// Current full-display mode. List commands check this to choose
54/// between table and full-form output.
55pub fn is_full_display() -> bool {
56    FULL_DISPLAY.load(Ordering::Relaxed)
57}
58
59/// Tell the reader that this table shortens identifiers, and how to get them
60/// whole.
61///
62/// The compact table is the right default — it is readable, and most of the
63/// time nobody needs the full value. But a shortened identifier still *looks*
64/// like an identifier, and the obvious thing to do with one is select it and
65/// paste it into the next command. That produces an argument containing a
66/// literal `…` (U+2026), which is not a DID and never was.
67///
68/// `--full-display` already existed. The gap was that you only learned it
69/// existed after being caught by its absence, so the hint goes where the
70/// truncation happens rather than in `--help`.
71pub fn print_truncation_hint() {
72    println!(
73        "  {DIM}Identifiers are shortened to fit. Re-run with `{} --full-display …` \
74         to copy one in full.{RESET}",
75        bin_name()
76    );
77}
78
79/// Emit a list entry as aligned `label: value` lines. Used in
80/// full-display mode where ratatui-Table truncation would hide full
81/// identifiers.
82///
83/// `pairs` is `[(label, value)]`. Labels are padded to the widest so
84/// values line up vertically. A trailing blank line separates entries.
85pub fn print_full_entry(pairs: &[(&str, &str)]) {
86    let widest = pairs.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
87    for (label, value) in pairs {
88        let pad = " ".repeat(widest.saturating_sub(label.len()));
89        println!("  {label}:{pad}  {DIM}{value}{RESET}");
90    }
91    println!();
92}
93
94/// [`print_full_entry`] for owned values. `crate::display::full_display_pairs`
95/// builds `(&'static str, String)` pairs — it has to, since a display name is
96/// computed rather than borrowed from the response — so this saves every call
97/// site the same re-borrowing dance.
98pub fn print_full_entry_owned(pairs: &[(&str, String)]) {
99    let borrowed: Vec<(&str, &str)> = pairs.iter().map(|(l, v)| (*l, v.as_str())).collect();
100    print_full_entry(&borrowed);
101}
102
103/// Print a bold section heading used above a list of full-display
104/// entries. Matches the title style of the table-mode block borders.
105pub fn print_full_list_title(title: &str, count: usize) {
106    println!();
107    println!("{BOLD}{title} ({count}){RESET}");
108    println!();
109}
110
111// ── Output format ───────────────────────────────────────────────────
112//
113// Global `--json` flag. When enabled, list commands emit a single JSON
114// document instead of the ratatui table / full-display rendering. This
115// is the automation entry point — scripts piping `pnm acl list --json`
116// into `jq` get a stable shape, while interactive operators get the
117// human-readable default.
118
119/// Output format selected by the operator.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum OutputFormat {
122    Human,
123    Json,
124}
125
126static OUTPUT_FORMAT: AtomicU8 = AtomicU8::new(0); // 0 = Human, 1 = Json
127
128/// Register the output format. Called once at CLI startup from the
129/// global `--json` flag.
130pub fn set_output_format(format: OutputFormat) {
131    OUTPUT_FORMAT.store(
132        match format {
133            OutputFormat::Human => 0,
134            OutputFormat::Json => 1,
135        },
136        Ordering::Relaxed,
137    );
138}
139
140/// Current output format. Default `Human`.
141pub fn output_format() -> OutputFormat {
142    if OUTPUT_FORMAT.load(Ordering::Relaxed) == 1 {
143        OutputFormat::Json
144    } else {
145        OutputFormat::Human
146    }
147}
148
149/// Returns true when the operator passed `--json`. List commands check
150/// this and dispatch to a JSON serializer instead of their human-
151/// readable renderer.
152#[must_use]
153pub fn is_json_output() -> bool {
154    output_format() == OutputFormat::Json
155}
156
157/// Pretty-print a serializable value as JSON to stdout. Used by list
158/// commands when [`is_json_output`] is true. Errors here are surfaced
159/// as a CLI error rather than a panic so the caller can render via
160/// `print_cli_error`.
161pub fn print_json<T: serde::Serialize>(value: &T) -> Result<(), serde_json::Error> {
162    let text = serde_json::to_string_pretty(value)?;
163    println!("{text}");
164    Ok(())
165}
166
167/// Print `value` as JSON, or exit reporting what could not be serialized.
168///
169/// A failure here is a bug in the shape being emitted, not something the
170/// caller can recover from or the operator can act on, so there is no
171/// `Result` worth threading back. `what` names the thing, so the message
172/// says which command produced the bad shape. Exits 1 — the generic
173/// operational failure both CLIs use.
174pub fn print_json_or_exit<T: serde::Serialize>(value: &T, what: &str) {
175    if let Err(e) = print_json(value) {
176        eprintln!("Error serializing {what}: {e}");
177        std::process::exit(1);
178    }
179}
180
181// ── Colour ──────────────────────────────────────────────────────────
182//
183// Colour is a property of the terminal, not of the data. Escape codes
184// written into a pipe or a log file are noise to a reader and a corrupt
185// field to a parser, so they are emitted only when the destination is a
186// terminal and the operator has not set `NO_COLOR`
187// (<https://no-color.org>). Default is on, so a caller that never
188// registers anything keeps the old behaviour.
189
190static COLOR: AtomicBool = AtomicBool::new(true);
191
192/// Enable or disable colour output. Called once at CLI startup from the
193/// terminal check.
194pub fn set_color(enabled: bool) {
195    COLOR.store(enabled, Ordering::Relaxed);
196}
197
198/// Whether colour may be emitted. Renderers check this before writing
199/// any escape sequence.
200#[must_use]
201pub fn color_enabled() -> bool {
202    COLOR.load(Ordering::Relaxed)
203}
204
205// ── ANSI constants ──────────────────────────────────────────────────
206
207/// An ANSI escape that writes itself only while [`color_enabled`].
208///
209/// Every `{GREEN}…{RESET}` in a format string goes through `Display`, so
210/// gating here is what makes `NO_COLOR` (and a non-terminal) reach the
211/// hundreds of hand-coloured lines, not only the table renderer.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub struct Ansi(&'static str);
214
215impl Ansi {
216    /// The raw escape sequence, whatever the colour setting.
217    #[must_use]
218    pub const fn code(self) -> &'static str {
219        self.0
220    }
221}
222
223impl std::fmt::Display for Ansi {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        if color_enabled() {
226            f.write_str(self.0)
227        } else {
228            Ok(())
229        }
230    }
231}
232
233pub const BOLD: Ansi = Ansi("\x1b[1m");
234pub const DIM: Ansi = Ansi("\x1b[2m");
235pub const GREEN: Ansi = Ansi("\x1b[32m");
236pub const RED: Ansi = Ansi("\x1b[31m");
237pub const CYAN: Ansi = Ansi("\x1b[36m");
238pub const YELLOW: Ansi = Ansi("\x1b[33m");
239pub const RESET: Ansi = Ansi("\x1b[0m");
240
241// ── Error reporting ─────────────────────────────────────────────────
242
243/// Print a CLI error to stderr in a form an operator can act on.
244///
245/// Downcasts to [`vta_sdk::error::VtaError`] when possible and emits a
246/// tailored remediation hint for the common failure modes (auth, network,
247/// forbidden, validation). Falls back to the raw error message + source
248/// chain for anything else, so unknown failures still get their underlying
249/// cause surfaced.
250///
251/// Call this from the top-level CLI match instead of `eprintln!("Error:
252/// {e}")` — the raw form loses auth/network context that operators need
253/// to fix things themselves.
254pub fn print_cli_error(err: &(dyn std::error::Error + 'static)) {
255    use vta_sdk::error::VtaError;
256    if let Some(vta_err) = err.downcast_ref::<VtaError>() {
257        match vta_err {
258            VtaError::Auth(msg) => {
259                eprintln!("{RED}\u{2717}{RESET} Authentication failed: {msg}");
260                eprintln!(
261                    "  {DIM}Token may be expired. Try `pnm setup` to re-authenticate, or check \
262                     that the VTA's `/auth` endpoint is reachable.{RESET}"
263                );
264            }
265            VtaError::Forbidden(msg) => {
266                eprintln!("{RED}\u{2717}{RESET} Forbidden: {msg}");
267                eprintln!(
268                    "  {DIM}Your role or context access doesn't permit this operation. \
269                     Inspect with `pnm acl get <your-did>`.{RESET}"
270                );
271            }
272            VtaError::NotFound(msg) => {
273                eprintln!("{RED}\u{2717}{RESET} Not found: {msg}");
274            }
275            VtaError::Conflict(msg) => {
276                // The SDK preserves the full 409 JSON body so programmatic
277                // callers can extract structured fields (e.g. `mediator_did`).
278                // For humans, surface the `message` (or `error`) field rather
279                // than dumping the raw JSON; fall back to the raw text for
280                // non-JSON bodies.
281                eprintln!(
282                    "{RED}\u{2717}{RESET} Conflict: {}",
283                    extract_human_message(msg)
284                );
285            }
286            VtaError::Gone(msg) => {
287                // Same treatment as the `Conflict` arm below: the SDK keeps
288                // the full 410 body, so surface the human field rather than
289                // dumping raw JSON at the operator.
290                let human = extract_human_message(msg);
291                eprintln!("{RED}\u{2717}{RESET} Resource is gone: {human}");
292                // 410 has more than one producer — the TEE bootstrap carve-out
293                // and the one-shot backup blob slots — so the carve-out
294                // walkthrough only prints when the refusal is actually about
295                // the carve-out. Unconditionally, it would send a backup
296                // operator down the admin-provisioning path for a bundle that
297                // simply expired. The marker is the server's own wording in
298                // `routes::bootstrap::carve_out_closed_error`; a miss costs
299                // the generic hint, never a wrong one.
300                if human.contains(CARVE_OUT_MARKER) {
301                    let bin = bin_name();
302                    eprintln!(
303                        "  {DIM}This usually means the bootstrap carve-out has already been \
304                         used. For a second admin, run `{bin} bootstrap provision-request` from \
305                         the new operator's host and have an existing admin run \
306                         `{bin} bootstrap provision-integration` against this VTA.{RESET}"
307                    );
308                } else {
309                    eprintln!(
310                        "  {DIM}This resource was single-use or time-limited, and has been \
311                         consumed or has expired. Retrying will not help — restart the \
312                         operation to get a fresh one.{RESET}"
313                    );
314                }
315            }
316            VtaError::Validation(msg) => {
317                eprintln!("{RED}\u{2717}{RESET} Invalid request: {msg}");
318            }
319            VtaError::RateLimited {
320                limited_by,
321                retry_after,
322                limiter,
323                url,
324            } => {
325                let (headline, hints) = rate_limit_message(
326                    *limited_by,
327                    *retry_after,
328                    limiter.as_deref(),
329                    url.as_deref(),
330                    chrono::Utc::now(),
331                    bin_name(),
332                );
333                eprintln!("{RED}\u{2717}{RESET} {headline}");
334                for hint in hints {
335                    eprintln!("  {DIM}{hint}{RESET}");
336                }
337            }
338            VtaError::Network(e) => {
339                eprintln!("{RED}\u{2717}{RESET} Network error: {e}");
340                eprintln!("  {DIM}Is the VTA reachable? Check its URL with `pnm vta info`.{RESET}");
341            }
342            VtaError::Server { status, body } => {
343                eprintln!("{RED}\u{2717}{RESET} Server error (HTTP {status}): {body}");
344                eprintln!(
345                    "  {DIM}This is a VTA-side failure. Check server logs or contact the operator.{RESET}"
346                );
347            }
348            VtaError::UnsupportedTransport(msg) => {
349                eprintln!("{RED}\u{2717}{RESET} Unsupported transport: {msg}");
350                eprintln!(
351                    "  {DIM}This operation requires a specific transport (REST or DIDComm). \
352                     Check which mode your CLI is in and whether the endpoint supports it.{RESET}"
353                );
354            }
355            VtaError::DidcommTransport(msg) => {
356                eprintln!("{RED}\u{2717}{RESET} DIDComm transport error: {msg}");
357                eprintln!(
358                    "  {DIM}Mediator or peer unreachable. Retry after checking mediator \
359                     connectivity.{RESET}"
360                );
361            }
362            VtaError::DidcommRemote { code, comment } => {
363                eprintln!("{RED}\u{2717}{RESET} Remote error ({code}): {comment}");
364            }
365            VtaError::Protocol(msg) => {
366                eprintln!("{RED}\u{2717}{RESET} Protocol error: {msg}");
367            }
368            VtaError::Rejected { code, message, .. } => {
369                eprintln!("{RED}\u{2717}{RESET} Refused [{code}]: {message}");
370            }
371            // ── Runtime service-management variants (T0.2) ────────
372            VtaError::LastServiceRefused => {
373                let bin = bin_name();
374                eprintln!(
375                    "{RED}\u{2717}{RESET} Refused: would leave the VTA with no advertised services."
376                );
377                eprintln!(
378                    "  {DIM}At least one transport (REST or DIDComm) must remain advertised. \
379                     Enable the other transport first via `{bin} services <kind> enable …`, \
380                     then retry.{RESET}"
381                );
382            }
383            VtaError::ServiceNotPresent => {
384                let bin = bin_name();
385                eprintln!("{RED}\u{2717}{RESET} Service is not present.");
386                eprintln!(
387                    "  {DIM}The service kind isn't currently enabled. Use `{bin} services \
388                     <kind> enable …` to bring it online before updating, disabling, or rolling \
389                     it back.{RESET}"
390                );
391            }
392            VtaError::ServiceAlreadyEnabled => {
393                let bin = bin_name();
394                eprintln!("{RED}\u{2717}{RESET} Service is already enabled.");
395                eprintln!(
396                    "  {DIM}Use `{bin} services <kind> update …` to change its configuration, \
397                     or `{bin} services <kind> disable` to remove it.{RESET}"
398                );
399            }
400            VtaError::MediatorHandshakeFailed { reason } => {
401                eprintln!("{RED}\u{2717}{RESET} Mediator handshake failed: {reason}");
402                eprintln!(
403                    "  {DIM}Confirm the mediator DID is correct and the mediator is reachable. \
404                     The reason above is the specific cause from the handshake protocol.{RESET}"
405                );
406            }
407            VtaError::DrainTtlOutOfBounds {
408                min,
409                max,
410                requested,
411            } => {
412                eprintln!(
413                    "{RED}\u{2717}{RESET} Drain TTL {requested}s is outside the allowed range \
414                     [{min}s, {max}s]."
415                );
416                eprintln!(
417                    "  {DIM}Pick a value within those bounds. The minimum applies when the \
418                     command is delivered over DIDComm transport (so the listener stays up long \
419                     enough for the response).{RESET}"
420                );
421            }
422            VtaError::NoMatchingProtocol {
423                counterparty_did,
424                ours,
425                theirs,
426            } => {
427                let (headline, hints) =
428                    no_matching_protocol_message(counterparty_did, ours, theirs, bin_name());
429                eprintln!("{RED}\u{2717}{RESET} {headline}");
430                for hint in hints {
431                    eprintln!("  {DIM}{hint}{RESET}");
432                }
433            }
434            VtaError::NoPriorMutation => {
435                let bin = bin_name();
436                eprintln!("{RED}\u{2717}{RESET} No prior mutation to roll back.");
437                eprintln!(
438                    "  {DIM}Use `{bin} services <kind> {{enable,update,disable}} …` directly \
439                     instead of rollback.{RESET}"
440                );
441            }
442            other => eprintln!("{RED}\u{2717}{RESET} Error: {other}"),
443        }
444        return;
445    }
446    eprintln!("{RED}\u{2717}{RESET} Error: {err}");
447    let mut source = err.source();
448    while let Some(s) = source {
449        eprintln!("  {DIM}caused by: {s}{RESET}");
450        source = s.source();
451    }
452}
453
454/// The operator-facing text for [`VtaError::NoMatchingProtocol`]: a headline,
455/// then the transports that would close the gap. Pure, like
456/// [`rate_limit_message`], so the wording is testable.
457///
458/// `ours` is the sender's set — this CLI's, or, when a VTA refused on the
459/// client's behalf, what that VTA can start for the document it was sending.
460/// The fix is a transport in both sets, and which side can add one is what the
461/// hints name.
462///
463/// [`VtaError::NoMatchingProtocol`]: vta_sdk::error::VtaError::NoMatchingProtocol
464fn no_matching_protocol_message(
465    peer: &str,
466    ours: &[vta_sdk::protocol::matching::Protocol],
467    theirs: &[vta_sdk::protocol::matching::Protocol],
468    bin: &str,
469) -> (String, Vec<String>) {
470    use vta_sdk::protocol::matching::Protocol;
471    let list = |set: &[Protocol]| {
472        if set.is_empty() {
473            "nothing".to_string()
474        } else {
475            set.iter()
476                .map(|p| p.as_str())
477                .collect::<Vec<_>>()
478                .join(", ")
479        }
480    };
481    let headline = format!(
482        "No transport in common with `{peer}`: it advertises [{}], and the sender can start [{}].",
483        list(theirs),
484        list(ours)
485    );
486    let mut hints = Vec::new();
487    if theirs.is_empty() {
488        hints.push(format!(
489            "`{peer}` advertises no TSP, DIDComm or Trust-Task HTTPS service in its DID \
490             document; nobody can reach it until it publishes one."
491        ));
492        return (headline, hints);
493    }
494    if !theirs.contains(&Protocol::Rest) {
495        hints.push(format!(
496            "Ask `{peer}`'s operator to publish a Trust-Task HTTPS service (`TrustTaskHTTPS`) \
497             in its DID document. It carries any signed document, including one signed as a \
498             room persona, which TSP and DIDComm cannot carry for a VTA."
499        ));
500    }
501    if theirs.contains(&Protocol::Didcomm) && !ours.contains(&Protocol::Didcomm) {
502        hints.push(format!(
503            "If the sending VTA has no DIDComm session, `{bin} services didcomm enable \
504             --mediator-did <did>` gives it one. That does not help a document signed as a \
505             room persona: DIDComm authenticates the VTA's own DID."
506        ));
507    }
508    if theirs.contains(&Protocol::Tsp) && !ours.contains(&Protocol::Tsp) {
509        hints.push(format!(
510            "If the sending VTA has no TSP session, `{bin} services tsp enable \
511             --mediator-did <did>` gives it one (same caveat for a persona-signed document)."
512        ));
513    }
514    (headline, hints)
515}
516
517/// The operator-facing text for a rate-limit refusal: a headline, then hint
518/// lines. Pure (no ANSI, `now` and the binary name passed in) so the wording is
519/// testable.
520///
521/// Answers the three things an operator needs, in order: *who* refused (a rate
522/// limit is not a fault, and not necessarily the VTA's), *how long* to wait,
523/// and *which knob* loosens it. The knob names come from
524/// `vta_sdk::rate_limit`, which holds every one of them once.
525fn rate_limit_message(
526    limited_by: vta_sdk::rate_limit::RateLimitSource,
527    retry_after: Option<chrono::DateTime<chrono::Utc>>,
528    limiter: Option<&str>,
529    url: Option<&str>,
530    now: chrono::DateTime<chrono::Utc>,
531    bin: &str,
532) -> (String, Vec<String>) {
533    use vta_sdk::rate_limit::{self as rl, RateLimitSource};
534
535    let scope = limiter;
536    let limiter = limiter.map(|l| format!(" ({l})")).unwrap_or_default();
537    let headline = format!(
538        "Rate limited by {}{limiter} — the request was refused, not failed.",
539        limited_by.label()
540    );
541
542    let mut hints = Vec::new();
543    hints.push(match retry_after {
544        Some(at) => {
545            // Round up: "wait 0s" for a 400 ms hint would invite an immediate,
546            // refused, retry.
547            let ms = (at - now).num_milliseconds().max(0);
548            let secs = (ms + 999) / 1000;
549            format!("Wait {secs}s before retrying.")
550        }
551        None => "No wait was given; wait a few seconds before retrying.".to_string(),
552    });
553    if let Some(url) = url {
554        hints.push(format!("Refused request: {url}"));
555    }
556
557    match limited_by {
558        // This client's own share of the operation budget: the fix is on the
559        // client's side, not a per-IP knob.
560        RateLimitSource::Vta if scope == Some(rl::OPERATIONS_SUBJECT_LIMITER) => {
561            hints.push(
562                "This client has used its own share of the VTA's operation budget; other \
563                 clients are not affected. Retry after the wait, or spread the work out."
564                    .to_string(),
565            );
566            hints.push(format!(
567                "The share is `[server] {}` (operations per second, per client DID); on a TEE \
568                 VTA it comes from the tenant overlay's `server` block. See {}.",
569                rl::VTA_OPERATION_PER_SUBJECT_KEY,
570                rl::VTA_DOCS,
571            ));
572        }
573        // The per-appliance budget, not a per-IP limiter: the per-IP knobs
574        // would not help, and the operator should know the whole VTA is busy.
575        RateLimitSource::Vta if scope == Some(rl::OPERATIONS_LIMITER) => {
576            hints.push(
577                "The VTA is at its operation budget: the whole appliance is at capacity, not \
578                 only this client. Retry after the wait."
579                    .to_string(),
580            );
581            hints.push(format!(
582                "The budget is `[server] {}` / `{}` (operations per second), sized for the \
583                 instance; on a TEE VTA it comes from the tenant overlay's `server` block. \
584                 Raise it only with a larger instance. See {}.",
585                rl::VTA_OPERATION_RATE_KEY,
586                rl::VTA_OPERATION_BURST_KEY,
587                rl::VTA_DOCS,
588            ));
589        }
590        RateLimitSource::Vta => {
591            hints.push(format!(
592                "To loosen it, in the VTA's `[server]` config: `{}` / `{}` for the auth and \
593                 bootstrap endpoints, `{}` / `{}` for the VTA's own did.jsonl. Intervals are \
594                 seconds per token, so lower is looser.",
595                rl::VTA_INTERVAL_KEY,
596                rl::VTA_BURST_KEY,
597                rl::VTA_DID_LOG_INTERVAL_KEY,
598                rl::VTA_DID_LOG_BURST_KEY,
599            ));
600            hints.push(format!(
601                "At runtime: `{bin} {}`, or `{bin} {}`.",
602                rl::VTA_RUNTIME_FLAGS,
603                rl::VTA_DID_LOG_RUNTIME_FLAGS,
604            ));
605            hints.push(format!(
606                "Behind a reverse proxy with `{} = false`, every client shares one bucket. \
607                 See {}.",
608                rl::VTA_TRUST_XFF_KEY,
609                rl::VTA_DOCS,
610            ));
611        }
612        RateLimitSource::Mediator => hints.push(format!(
613            "This is the DIDComm/TSP mediator, not the VTA. Its operator tunes {} — requests \
614             per second, so higher is looser.",
615            rl::MEDIATOR_KEYS,
616        )),
617        RateLimitSource::Upstream => {
618            hints.push(format!(
619                "The response carried no `{}` header, so nothing says who sent it: a reverse \
620                 proxy or load balancer in front of the VTA, or a VTA older than that header.",
621                rl::SOURCE_HEADER,
622            ));
623            hints.push(format!(
624                "Check the proxy / load balancer's limits and logs, or upgrade the VTA so its \
625                 own refusals are labelled. See {}.",
626                rl::VTA_DOCS,
627            ));
628        }
629        // VTC, DID host, and any source added later: the SDK's hint is the
630        // whole story — none of them has a knob this CLI can name.
631        other => hints.push(rl::suggested_fix(other).to_string()),
632    }
633    (headline, hints)
634}
635
636/// Pull a human-readable line out of a (possibly JSON) error body.
637///
638/// The SDK hands `VtaError::Conflict` the *raw* 409 response body so that
639/// programmatic callers can deserialize structured fields. For terminal
640/// output we don't want to dump JSON at the operator, so prefer the body's
641/// `message` field, then its `error` field, and only fall back to the raw
642/// text when the body isn't the expected JSON shape.
643/// Substring that marks a 410 as the TEE first-boot bootstrap carve-out
644/// rather than one of the other single-use resources that now render as
645/// `Gone` (the backup blob slots). Mirrors the server's wording in
646/// `vta_service::routes::bootstrap::carve_out_closed_error`.
647const CARVE_OUT_MARKER: &str = "carve-out";
648
649fn extract_human_message(body: &str) -> String {
650    serde_json::from_str::<serde_json::Value>(body)
651        .ok()
652        .and_then(|v| {
653            v.get("message")
654                .or_else(|| v.get("error"))
655                .and_then(|m| m.as_str())
656                .map(str::to_string)
657        })
658        .unwrap_or_else(|| body.to_string())
659}
660
661// ── Ratatui rendering helpers ───────────────────────────────────────
662
663pub fn print_widget(widget: impl Widget, height: u16) {
664    print!("{}", render_widget(widget, height, color_enabled()));
665}
666
667/// Render `widget` to the string `print_widget` would emit.
668///
669/// Takes `color` rather than reading the global, so the escape-sequence
670/// behaviour — the whole point of the gate — is testable without
671/// capturing stdout and without tests racing each other over one flag.
672fn render_widget(widget: impl Widget, height: u16, color: bool) -> String {
673    let width = ratatui::crossterm::terminal::size().map_or(120, |(w, _)| w);
674    let area = Rect::new(0, 0, width, height);
675    let mut buf = Buffer::empty(area);
676    widget.render(area, &mut buf);
677
678    let mut out = String::new();
679    for y in 0..height {
680        let mut cur_fg = Color::Reset;
681        let mut cur_bg = Color::Reset;
682        let mut cur_mod = Modifier::empty();
683        let mut line = String::new();
684
685        for x in 0..width {
686            let cell = &buf[(x, y)];
687            if cell.diff_option == CellDiffOption::Skip {
688                continue;
689            }
690
691            if color && (cell.fg != cur_fg || cell.bg != cur_bg || cell.modifier != cur_mod) {
692                line.push_str("\x1b[0m");
693                push_ansi_fg(&mut line, cell.fg);
694                push_ansi_bg(&mut line, cell.bg);
695                push_ansi_mod(&mut line, cell.modifier);
696                cur_fg = cell.fg;
697                cur_bg = cell.bg;
698                cur_mod = cell.modifier;
699            }
700
701            line.push_str(cell.symbol());
702        }
703
704        if color {
705            out.push_str(&line);
706            out.push_str("\x1b[0m\n");
707        } else {
708            // Every row is padded to the terminal width; uncoloured that
709            // padding is just trailing blanks in someone's file.
710            out.push_str(line.trim_end());
711            out.push('\n');
712        }
713    }
714
715    out
716}
717
718pub fn push_ansi_fg(out: &mut String, color: Color) {
719    use std::fmt::Write as _;
720    match color {
721        Color::Reset => {}
722        Color::Black => out.push_str("\x1b[30m"),
723        Color::Red => out.push_str("\x1b[31m"),
724        Color::Green => out.push_str("\x1b[32m"),
725        Color::Yellow => out.push_str("\x1b[33m"),
726        Color::Blue => out.push_str("\x1b[34m"),
727        Color::Magenta => out.push_str("\x1b[35m"),
728        Color::Cyan => out.push_str("\x1b[36m"),
729        Color::Gray => out.push_str("\x1b[37m"),
730        Color::DarkGray => out.push_str("\x1b[90m"),
731        Color::LightRed => out.push_str("\x1b[91m"),
732        Color::LightGreen => out.push_str("\x1b[92m"),
733        Color::LightYellow => out.push_str("\x1b[93m"),
734        Color::LightBlue => out.push_str("\x1b[94m"),
735        Color::LightMagenta => out.push_str("\x1b[95m"),
736        Color::LightCyan => out.push_str("\x1b[96m"),
737        Color::White => out.push_str("\x1b[97m"),
738        Color::Rgb(r, g, b) => {
739            let _ = write!(out, "\x1b[38;2;{r};{g};{b}m");
740        }
741        Color::Indexed(i) => {
742            let _ = write!(out, "\x1b[38;5;{i}m");
743        }
744    }
745}
746
747pub fn push_ansi_bg(out: &mut String, color: Color) {
748    use std::fmt::Write as _;
749    match color {
750        Color::Reset => {}
751        Color::Black => out.push_str("\x1b[40m"),
752        Color::Red => out.push_str("\x1b[41m"),
753        Color::Green => out.push_str("\x1b[42m"),
754        Color::Yellow => out.push_str("\x1b[43m"),
755        Color::Blue => out.push_str("\x1b[44m"),
756        Color::Magenta => out.push_str("\x1b[45m"),
757        Color::Cyan => out.push_str("\x1b[46m"),
758        Color::Gray => out.push_str("\x1b[47m"),
759        Color::DarkGray => out.push_str("\x1b[100m"),
760        Color::LightRed => out.push_str("\x1b[101m"),
761        Color::LightGreen => out.push_str("\x1b[102m"),
762        Color::LightYellow => out.push_str("\x1b[103m"),
763        Color::LightBlue => out.push_str("\x1b[104m"),
764        Color::LightMagenta => out.push_str("\x1b[105m"),
765        Color::LightCyan => out.push_str("\x1b[106m"),
766        Color::White => out.push_str("\x1b[107m"),
767        Color::Rgb(r, g, b) => {
768            let _ = write!(out, "\x1b[48;2;{r};{g};{b}m");
769        }
770        Color::Indexed(i) => {
771            let _ = write!(out, "\x1b[48;5;{i}m");
772        }
773    }
774}
775
776pub fn push_ansi_mod(out: &mut String, modifier: Modifier) {
777    if modifier.contains(Modifier::BOLD) {
778        out.push_str("\x1b[1m");
779    }
780    if modifier.contains(Modifier::DIM) {
781        out.push_str("\x1b[2m");
782    }
783    if modifier.contains(Modifier::ITALIC) {
784        out.push_str("\x1b[3m");
785    }
786    if modifier.contains(Modifier::UNDERLINED) {
787        out.push_str("\x1b[4m");
788    }
789    if modifier.contains(Modifier::REVERSED) {
790        out.push_str("\x1b[7m");
791    }
792    if modifier.contains(Modifier::CROSSED_OUT) {
793        out.push_str("\x1b[9m");
794    }
795}
796
797pub fn print_section(title: &str) {
798    let pad = 46usize.saturating_sub(title.len());
799    println!(
800        "\n{DIM}──{RESET} {BOLD}{title}{RESET} {DIM}{}{RESET}",
801        "─".repeat(pad)
802    );
803}
804
805#[cfg(test)]
806mod rate_limit_render_tests {
807    use super::rate_limit_message;
808    use vta_sdk::rate_limit::RateLimitSource;
809
810    fn now() -> chrono::DateTime<chrono::Utc> {
811        chrono::DateTime::parse_from_rfc3339("2026-09-16T12:00:00Z")
812            .unwrap()
813            .with_timezone(&chrono::Utc)
814    }
815
816    #[test]
817    fn a_vta_refusal_names_the_vta_the_wait_and_every_knob() {
818        let (headline, hints) = rate_limit_message(
819            RateLimitSource::Vta,
820            Some(now() + chrono::Duration::milliseconds(3_200)),
821            Some("auth"),
822            Some("https://vta.example.com/auth/challenge"),
823            now(),
824            "pnm",
825        );
826        assert_eq!(
827            headline,
828            "Rate limited by the VTA (auth) — the request was refused, not failed."
829        );
830        let all = hints.join("\n");
831        assert_eq!(hints[0], "Wait 4s before retrying.", "rounded up");
832        for needle in [
833            "Refused request: https://vta.example.com/auth/challenge",
834            "`rate_limit_interval_secs` / `rate_limit_burst`",
835            "`did_log_rate_limit_interval_secs` / `did_log_rate_limit_burst`",
836            "lower is looser",
837            "`pnm config update --rate-limit-interval-secs <N> --rate-limit-burst <N>`",
838            "`pnm config update --did-log-rate-limit-interval-secs <N> --did-log-rate-limit-burst <N>`",
839            "`trust_xff = false`",
840            "docs/02-vta/rate-limiting.md",
841        ] {
842            assert!(all.contains(needle), "missing {needle:?} in:\n{all}");
843        }
844    }
845
846    #[test]
847    fn an_operation_budget_refusal_names_the_budget_not_the_per_ip_knobs() {
848        let (headline, hints) = rate_limit_message(
849            RateLimitSource::Vta,
850            Some(now() + chrono::Duration::seconds(1)),
851            Some("operations"),
852            None,
853            now(),
854            "pnm",
855        );
856        assert!(headline.contains("(operations)"), "{headline}");
857        let all = hints.join("\n");
858        assert!(
859            all.contains("`[server] operation_rate_limit_per_sec`"),
860            "{all}"
861        );
862        assert!(all.contains("tenant overlay"), "{all}");
863        assert!(
864            !all.contains("config update"),
865            "the budget is not runtime-patchable, so no `config update` hint:\n{all}"
866        );
867    }
868
869    #[test]
870    fn a_per_client_refusal_names_the_clients_share() {
871        let (_, hints) = rate_limit_message(
872            RateLimitSource::Vta,
873            None,
874            Some("operations-subject"),
875            None,
876            now(),
877            "pnm",
878        );
879        let all = hints.join("\n");
880        assert!(all.contains("its own share"), "{all}");
881        assert!(
882            all.contains("`[server] operation_rate_limit_per_subject_per_sec`"),
883            "{all}"
884        );
885        assert!(!all.contains("config update"), "{all}");
886    }
887
888    #[test]
889    fn an_unlabelled_refusal_points_at_the_proxy_and_uses_the_invoked_binary() {
890        let (headline, hints) =
891            rate_limit_message(RateLimitSource::Upstream, None, None, None, now(), "cnm");
892        assert!(headline.contains("unidentified service"), "{headline}");
893        let all = hints.join("\n");
894        assert!(all.contains("No wait was given"), "{all}");
895        assert!(all.contains("`x-rate-limit-source`"), "{all}");
896        assert!(all.contains("load balancer"), "{all}");
897        assert!(
898            !all.contains("config update"),
899            "an unattributed 429 must not send the operator to retune the VTA:\n{all}"
900        );
901    }
902
903    #[test]
904    fn a_mediator_refusal_names_the_mediator_limits_not_the_vta_config() {
905        let (headline, hints) = rate_limit_message(
906            RateLimitSource::Mediator,
907            Some(now() - chrono::Duration::seconds(5)),
908            None,
909            None,
910            now(),
911            "pnm",
912        );
913        assert!(headline.contains("the mediator"), "{headline}");
914        let all = hints.join("\n");
915        assert!(all.contains("Wait 0s"), "a stale hint is no wait: {all}");
916        assert!(all.contains("rate_limit_per_ip"), "{all}");
917        assert!(!all.contains("rate_limit_interval_secs"), "{all}");
918    }
919
920    #[test]
921    fn a_did_host_refusal_says_it_is_not_tunable_from_the_vta() {
922        let (_, hints) =
923            rate_limit_message(RateLimitSource::DidHost, None, None, None, now(), "pnm");
924        assert!(hints.join("\n").contains("not tunable from the VTA"));
925    }
926}
927
928#[cfg(test)]
929mod tests {
930    use super::{extract_human_message, no_matching_protocol_message, render_widget};
931
932    /// The live case: a persona-signed room request from a VTA that can only
933    /// use REST for it, to a host advertising DIDComm alone. The operator is
934    /// told both sets and that the host needs a Trust-Task HTTPS service.
935    #[test]
936    fn no_matching_protocol_names_both_sets_and_the_missing_service() {
937        use vta_sdk::protocol::matching::Protocol;
938        let (headline, hints) = no_matching_protocol_message(
939            "did:webvh:vtc",
940            &[Protocol::Rest],
941            &[Protocol::Didcomm],
942            "pnm",
943        );
944        assert!(
945            headline.contains("advertises [didcomm]") && headline.contains("start [rest]"),
946            "{headline}"
947        );
948        let all = hints.join("\n");
949        assert!(all.contains("TrustTaskHTTPS"), "{all}");
950        assert!(all.contains("pnm services didcomm enable"), "{all}");
951        assert!(!all.contains("services tsp"), "TSP is not on offer: {all}");
952    }
953
954    #[test]
955    fn no_matching_protocol_with_nothing_advertised_says_so() {
956        let (headline, hints) = no_matching_protocol_message("did:example:p", &[], &[], "pnm");
957        assert!(headline.contains("advertises [nothing]"), "{headline}");
958        assert_eq!(hints.len(), 1);
959        assert!(hints[0].contains("no TSP, DIDComm or Trust-Task HTTPS"));
960    }
961
962    #[test]
963    fn prefers_message_field() {
964        let body = r#"{"error":"didcomm_already_enabled","message":"DIDComm is already enabled.","mediator_did":"did:peer:2.med"}"#;
965        assert_eq!(extract_human_message(body), "DIDComm is already enabled.");
966    }
967
968    #[test]
969    fn falls_back_to_error_field_when_no_message() {
970        let body = r#"{"error":"duplicate_key"}"#;
971        assert_eq!(extract_human_message(body), "duplicate_key");
972    }
973
974    #[test]
975    fn falls_back_to_raw_text_for_non_json() {
976        let body = "plain conflict text";
977        assert_eq!(extract_human_message(body), "plain conflict text");
978    }
979
980    #[test]
981    fn falls_back_to_raw_text_when_fields_missing() {
982        // Valid JSON but neither `message` nor `error` present → raw text.
983        let body = r#"{"detail":"something"}"#;
984        assert_eq!(extract_human_message(body), body);
985    }
986
987    // ── Colour gate ─────────────────────────────────────────────────
988
989    use ratatui::style::{Color, Style};
990    use ratatui::widgets::Paragraph;
991
992    fn coloured_line() -> Paragraph<'static> {
993        Paragraph::new("hello").style(Style::default().fg(Color::Red))
994    }
995
996    #[test]
997    fn test_widget_colour_enabled_emits_escapes() {
998        let out = render_widget(coloured_line(), 1, true);
999        assert!(
1000            out.contains('\u{1b}'),
1001            "expected escape sequences with colour on, got {out:?}"
1002        );
1003    }
1004
1005    #[test]
1006    fn test_widget_colour_disabled_emits_none() {
1007        let out = render_widget(coloured_line(), 1, false);
1008        assert!(
1009            !out.contains('\u{1b}'),
1010            "no escape may reach a non-terminal, got {out:?}"
1011        );
1012        assert!(out.contains("hello"), "the text itself must survive");
1013    }
1014
1015    #[test]
1016    fn test_widget_colour_disabled_trims_row_padding() {
1017        let out = render_widget(coloured_line(), 1, false);
1018        for line in out.lines() {
1019            assert_eq!(
1020                line.trim_end(),
1021                line,
1022                "rows are padded to terminal width; uncoloured that is trailing blanks"
1023            );
1024        }
1025    }
1026}