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// ── ANSI constants ──────────────────────────────────────────────────
168
169pub const BOLD: &str = "\x1b[1m";
170pub const DIM: &str = "\x1b[2m";
171pub const GREEN: &str = "\x1b[32m";
172pub const RED: &str = "\x1b[31m";
173pub const CYAN: &str = "\x1b[36m";
174pub const YELLOW: &str = "\x1b[33m";
175pub const RESET: &str = "\x1b[0m";
176
177// ── Error reporting ─────────────────────────────────────────────────
178
179/// Print a CLI error to stderr in a form an operator can act on.
180///
181/// Downcasts to [`vta_sdk::error::VtaError`] when possible and emits a
182/// tailored remediation hint for the common failure modes (auth, network,
183/// forbidden, validation). Falls back to the raw error message + source
184/// chain for anything else, so unknown failures still get their underlying
185/// cause surfaced.
186///
187/// Call this from the top-level CLI match instead of `eprintln!("Error:
188/// {e}")` — the raw form loses auth/network context that operators need
189/// to fix things themselves.
190pub fn print_cli_error(err: &(dyn std::error::Error + 'static)) {
191    use vta_sdk::error::VtaError;
192    if let Some(vta_err) = err.downcast_ref::<VtaError>() {
193        match vta_err {
194            VtaError::Auth(msg) => {
195                eprintln!("{RED}\u{2717}{RESET} Authentication failed: {msg}");
196                eprintln!(
197                    "  {DIM}Token may be expired. Try `pnm setup` to re-authenticate, or check \
198                     that the VTA's `/auth` endpoint is reachable.{RESET}"
199                );
200            }
201            VtaError::Forbidden(msg) => {
202                eprintln!("{RED}\u{2717}{RESET} Forbidden: {msg}");
203                eprintln!(
204                    "  {DIM}Your role or context access doesn't permit this operation. \
205                     Inspect with `pnm acl get <your-did>`.{RESET}"
206                );
207            }
208            VtaError::NotFound(msg) => {
209                eprintln!("{RED}\u{2717}{RESET} Not found: {msg}");
210            }
211            VtaError::Conflict(msg) => {
212                // The SDK preserves the full 409 JSON body so programmatic
213                // callers can extract structured fields (e.g. `mediator_did`).
214                // For humans, surface the `message` (or `error`) field rather
215                // than dumping the raw JSON; fall back to the raw text for
216                // non-JSON bodies.
217                eprintln!(
218                    "{RED}\u{2717}{RESET} Conflict: {}",
219                    extract_human_message(msg)
220                );
221            }
222            VtaError::Gone(msg) => {
223                // Same treatment as the `Conflict` arm below: the SDK keeps
224                // the full 410 body, so surface the human field rather than
225                // dumping raw JSON at the operator.
226                let human = extract_human_message(msg);
227                eprintln!("{RED}\u{2717}{RESET} Resource is gone: {human}");
228                // 410 has more than one producer — the TEE bootstrap carve-out
229                // and the one-shot backup blob slots — so the carve-out
230                // walkthrough only prints when the refusal is actually about
231                // the carve-out. Unconditionally, it would send a backup
232                // operator down the admin-provisioning path for a bundle that
233                // simply expired. The marker is the server's own wording in
234                // `routes::bootstrap::carve_out_closed_error`; a miss costs
235                // the generic hint, never a wrong one.
236                if human.contains(CARVE_OUT_MARKER) {
237                    let bin = bin_name();
238                    eprintln!(
239                        "  {DIM}This usually means the bootstrap carve-out has already been \
240                         used. For a second admin, run `{bin} bootstrap provision-request` from \
241                         the new operator's host and have an existing admin run \
242                         `{bin} bootstrap provision-integration` against this VTA.{RESET}"
243                    );
244                } else {
245                    eprintln!(
246                        "  {DIM}This resource was single-use or time-limited, and has been \
247                         consumed or has expired. Retrying will not help — restart the \
248                         operation to get a fresh one.{RESET}"
249                    );
250                }
251            }
252            VtaError::Validation(msg) => {
253                eprintln!("{RED}\u{2717}{RESET} Invalid request: {msg}");
254            }
255            VtaError::RateLimited {
256                limited_by,
257                retry_after,
258                limiter,
259                url,
260            } => {
261                let (headline, hints) = rate_limit_message(
262                    *limited_by,
263                    *retry_after,
264                    limiter.as_deref(),
265                    url.as_deref(),
266                    chrono::Utc::now(),
267                    bin_name(),
268                );
269                eprintln!("{RED}\u{2717}{RESET} {headline}");
270                for hint in hints {
271                    eprintln!("  {DIM}{hint}{RESET}");
272                }
273            }
274            VtaError::Network(e) => {
275                eprintln!("{RED}\u{2717}{RESET} Network error: {e}");
276                eprintln!("  {DIM}Is the VTA reachable? Check its URL with `pnm vta info`.{RESET}");
277            }
278            VtaError::Server { status, body } => {
279                eprintln!("{RED}\u{2717}{RESET} Server error (HTTP {status}): {body}");
280                eprintln!(
281                    "  {DIM}This is a VTA-side failure. Check server logs or contact the operator.{RESET}"
282                );
283            }
284            VtaError::UnsupportedTransport(msg) => {
285                eprintln!("{RED}\u{2717}{RESET} Unsupported transport: {msg}");
286                eprintln!(
287                    "  {DIM}This operation requires a specific transport (REST or DIDComm). \
288                     Check which mode your CLI is in and whether the endpoint supports it.{RESET}"
289                );
290            }
291            VtaError::DidcommTransport(msg) => {
292                eprintln!("{RED}\u{2717}{RESET} DIDComm transport error: {msg}");
293                eprintln!(
294                    "  {DIM}Mediator or peer unreachable. Retry after checking mediator \
295                     connectivity.{RESET}"
296                );
297            }
298            VtaError::DidcommRemote { code, comment } => {
299                eprintln!("{RED}\u{2717}{RESET} Remote error ({code}): {comment}");
300            }
301            VtaError::Protocol(msg) => {
302                eprintln!("{RED}\u{2717}{RESET} Protocol error: {msg}");
303            }
304            // ── Runtime service-management variants (T0.2) ────────
305            VtaError::LastServiceRefused => {
306                let bin = bin_name();
307                eprintln!(
308                    "{RED}\u{2717}{RESET} Refused: would leave the VTA with no advertised services."
309                );
310                eprintln!(
311                    "  {DIM}At least one transport (REST or DIDComm) must remain advertised. \
312                     Enable the other transport first via `{bin} services <kind> enable …`, \
313                     then retry.{RESET}"
314                );
315            }
316            VtaError::ServiceNotPresent => {
317                let bin = bin_name();
318                eprintln!("{RED}\u{2717}{RESET} Service is not present.");
319                eprintln!(
320                    "  {DIM}The service kind isn't currently enabled. Use `{bin} services \
321                     <kind> enable …` to bring it online before updating, disabling, or rolling \
322                     it back.{RESET}"
323                );
324            }
325            VtaError::ServiceAlreadyEnabled => {
326                let bin = bin_name();
327                eprintln!("{RED}\u{2717}{RESET} Service is already enabled.");
328                eprintln!(
329                    "  {DIM}Use `{bin} services <kind> update …` to change its configuration, \
330                     or `{bin} services <kind> disable` to remove it.{RESET}"
331                );
332            }
333            VtaError::MediatorHandshakeFailed { reason } => {
334                eprintln!("{RED}\u{2717}{RESET} Mediator handshake failed: {reason}");
335                eprintln!(
336                    "  {DIM}Confirm the mediator DID is correct and the mediator is reachable. \
337                     The reason above is the specific cause from the handshake protocol.{RESET}"
338                );
339            }
340            VtaError::DrainTtlOutOfBounds {
341                min,
342                max,
343                requested,
344            } => {
345                eprintln!(
346                    "{RED}\u{2717}{RESET} Drain TTL {requested}s is outside the allowed range \
347                     [{min}s, {max}s]."
348                );
349                eprintln!(
350                    "  {DIM}Pick a value within those bounds. The minimum applies when the \
351                     command is delivered over DIDComm transport (so the listener stays up long \
352                     enough for the response).{RESET}"
353                );
354            }
355            VtaError::NoPriorMutation => {
356                let bin = bin_name();
357                eprintln!("{RED}\u{2717}{RESET} No prior mutation to roll back.");
358                eprintln!(
359                    "  {DIM}Use `{bin} services <kind> {{enable,update,disable}} …` directly \
360                     instead of rollback.{RESET}"
361                );
362            }
363            other => eprintln!("{RED}\u{2717}{RESET} Error: {other}"),
364        }
365        return;
366    }
367    eprintln!("{RED}\u{2717}{RESET} Error: {err}");
368    let mut source = err.source();
369    while let Some(s) = source {
370        eprintln!("  {DIM}caused by: {s}{RESET}");
371        source = s.source();
372    }
373}
374
375/// The operator-facing text for a rate-limit refusal: a headline, then hint
376/// lines. Pure (no ANSI, `now` and the binary name passed in) so the wording is
377/// testable.
378///
379/// Answers the three things an operator needs, in order: *who* refused (a rate
380/// limit is not a fault, and not necessarily the VTA's), *how long* to wait,
381/// and *which knob* loosens it. The knob names come from
382/// `vta_sdk::rate_limit`, which holds every one of them once.
383fn rate_limit_message(
384    limited_by: vta_sdk::rate_limit::RateLimitSource,
385    retry_after: Option<chrono::DateTime<chrono::Utc>>,
386    limiter: Option<&str>,
387    url: Option<&str>,
388    now: chrono::DateTime<chrono::Utc>,
389    bin: &str,
390) -> (String, Vec<String>) {
391    use vta_sdk::rate_limit::{self as rl, RateLimitSource};
392
393    let limiter = limiter.map(|l| format!(" ({l})")).unwrap_or_default();
394    let headline = format!(
395        "Rate limited by {}{limiter} — the request was refused, not failed.",
396        limited_by.label()
397    );
398
399    let mut hints = Vec::new();
400    hints.push(match retry_after {
401        Some(at) => {
402            // Round up: "wait 0s" for a 400 ms hint would invite an immediate,
403            // refused, retry.
404            let ms = (at - now).num_milliseconds().max(0);
405            let secs = (ms + 999) / 1000;
406            format!("Wait {secs}s before retrying.")
407        }
408        None => "No wait was given; wait a few seconds before retrying.".to_string(),
409    });
410    if let Some(url) = url {
411        hints.push(format!("Refused request: {url}"));
412    }
413
414    match limited_by {
415        RateLimitSource::Vta => {
416            hints.push(format!(
417                "To loosen it, in the VTA's `[server]` config: `{}` / `{}` for the auth and \
418                 bootstrap endpoints, `{}` / `{}` for the VTA's own did.jsonl. Intervals are \
419                 seconds per token, so lower is looser.",
420                rl::VTA_INTERVAL_KEY,
421                rl::VTA_BURST_KEY,
422                rl::VTA_DID_LOG_INTERVAL_KEY,
423                rl::VTA_DID_LOG_BURST_KEY,
424            ));
425            hints.push(format!(
426                "At runtime: `{bin} {}`, or `{bin} {}`.",
427                rl::VTA_RUNTIME_FLAGS,
428                rl::VTA_DID_LOG_RUNTIME_FLAGS,
429            ));
430            hints.push(format!(
431                "Behind a reverse proxy with `{} = false`, every client shares one bucket. \
432                 See {}.",
433                rl::VTA_TRUST_XFF_KEY,
434                rl::VTA_DOCS,
435            ));
436        }
437        RateLimitSource::Mediator => hints.push(format!(
438            "This is the DIDComm/TSP mediator, not the VTA. Its operator tunes {} — requests \
439             per second, so higher is looser.",
440            rl::MEDIATOR_KEYS,
441        )),
442        RateLimitSource::Upstream => {
443            hints.push(format!(
444                "The response carried no `{}` header, so nothing says who sent it: a reverse \
445                 proxy or load balancer in front of the VTA, or a VTA older than that header.",
446                rl::SOURCE_HEADER,
447            ));
448            hints.push(format!(
449                "Check the proxy / load balancer's limits and logs, or upgrade the VTA so its \
450                 own refusals are labelled. See {}.",
451                rl::VTA_DOCS,
452            ));
453        }
454        // VTC, DID host, and any source added later: the SDK's hint is the
455        // whole story — none of them has a knob this CLI can name.
456        other => hints.push(rl::suggested_fix(other).to_string()),
457    }
458    (headline, hints)
459}
460
461/// Pull a human-readable line out of a (possibly JSON) error body.
462///
463/// The SDK hands `VtaError::Conflict` the *raw* 409 response body so that
464/// programmatic callers can deserialize structured fields. For terminal
465/// output we don't want to dump JSON at the operator, so prefer the body's
466/// `message` field, then its `error` field, and only fall back to the raw
467/// text when the body isn't the expected JSON shape.
468/// Substring that marks a 410 as the TEE first-boot bootstrap carve-out
469/// rather than one of the other single-use resources that now render as
470/// `Gone` (the backup blob slots). Mirrors the server's wording in
471/// `vta_service::routes::bootstrap::carve_out_closed_error`.
472const CARVE_OUT_MARKER: &str = "carve-out";
473
474fn extract_human_message(body: &str) -> String {
475    serde_json::from_str::<serde_json::Value>(body)
476        .ok()
477        .and_then(|v| {
478            v.get("message")
479                .or_else(|| v.get("error"))
480                .and_then(|m| m.as_str())
481                .map(str::to_string)
482        })
483        .unwrap_or_else(|| body.to_string())
484}
485
486// ── Ratatui rendering helpers ───────────────────────────────────────
487
488pub fn print_widget(widget: impl Widget, height: u16) {
489    let width = ratatui::crossterm::terminal::size().map_or(120, |(w, _)| w);
490    let area = Rect::new(0, 0, width, height);
491    let mut buf = Buffer::empty(area);
492    widget.render(area, &mut buf);
493
494    let mut out = String::new();
495    for y in 0..height {
496        let mut cur_fg = Color::Reset;
497        let mut cur_bg = Color::Reset;
498        let mut cur_mod = Modifier::empty();
499
500        for x in 0..width {
501            let cell = &buf[(x, y)];
502            if cell.diff_option == CellDiffOption::Skip {
503                continue;
504            }
505
506            if cell.fg != cur_fg || cell.bg != cur_bg || cell.modifier != cur_mod {
507                out.push_str("\x1b[0m");
508                push_ansi_fg(&mut out, cell.fg);
509                push_ansi_bg(&mut out, cell.bg);
510                push_ansi_mod(&mut out, cell.modifier);
511                cur_fg = cell.fg;
512                cur_bg = cell.bg;
513                cur_mod = cell.modifier;
514            }
515
516            out.push_str(cell.symbol());
517        }
518        out.push_str("\x1b[0m\n");
519    }
520
521    print!("{out}");
522}
523
524pub fn push_ansi_fg(out: &mut String, color: Color) {
525    use std::fmt::Write as _;
526    match color {
527        Color::Reset => {}
528        Color::Black => out.push_str("\x1b[30m"),
529        Color::Red => out.push_str("\x1b[31m"),
530        Color::Green => out.push_str("\x1b[32m"),
531        Color::Yellow => out.push_str("\x1b[33m"),
532        Color::Blue => out.push_str("\x1b[34m"),
533        Color::Magenta => out.push_str("\x1b[35m"),
534        Color::Cyan => out.push_str("\x1b[36m"),
535        Color::Gray => out.push_str("\x1b[37m"),
536        Color::DarkGray => out.push_str("\x1b[90m"),
537        Color::LightRed => out.push_str("\x1b[91m"),
538        Color::LightGreen => out.push_str("\x1b[92m"),
539        Color::LightYellow => out.push_str("\x1b[93m"),
540        Color::LightBlue => out.push_str("\x1b[94m"),
541        Color::LightMagenta => out.push_str("\x1b[95m"),
542        Color::LightCyan => out.push_str("\x1b[96m"),
543        Color::White => out.push_str("\x1b[97m"),
544        Color::Rgb(r, g, b) => {
545            let _ = write!(out, "\x1b[38;2;{r};{g};{b}m");
546        }
547        Color::Indexed(i) => {
548            let _ = write!(out, "\x1b[38;5;{i}m");
549        }
550    }
551}
552
553pub fn push_ansi_bg(out: &mut String, color: Color) {
554    use std::fmt::Write as _;
555    match color {
556        Color::Reset => {}
557        Color::Black => out.push_str("\x1b[40m"),
558        Color::Red => out.push_str("\x1b[41m"),
559        Color::Green => out.push_str("\x1b[42m"),
560        Color::Yellow => out.push_str("\x1b[43m"),
561        Color::Blue => out.push_str("\x1b[44m"),
562        Color::Magenta => out.push_str("\x1b[45m"),
563        Color::Cyan => out.push_str("\x1b[46m"),
564        Color::Gray => out.push_str("\x1b[47m"),
565        Color::DarkGray => out.push_str("\x1b[100m"),
566        Color::LightRed => out.push_str("\x1b[101m"),
567        Color::LightGreen => out.push_str("\x1b[102m"),
568        Color::LightYellow => out.push_str("\x1b[103m"),
569        Color::LightBlue => out.push_str("\x1b[104m"),
570        Color::LightMagenta => out.push_str("\x1b[105m"),
571        Color::LightCyan => out.push_str("\x1b[106m"),
572        Color::White => out.push_str("\x1b[107m"),
573        Color::Rgb(r, g, b) => {
574            let _ = write!(out, "\x1b[48;2;{r};{g};{b}m");
575        }
576        Color::Indexed(i) => {
577            let _ = write!(out, "\x1b[48;5;{i}m");
578        }
579    }
580}
581
582pub fn push_ansi_mod(out: &mut String, modifier: Modifier) {
583    if modifier.contains(Modifier::BOLD) {
584        out.push_str("\x1b[1m");
585    }
586    if modifier.contains(Modifier::DIM) {
587        out.push_str("\x1b[2m");
588    }
589    if modifier.contains(Modifier::ITALIC) {
590        out.push_str("\x1b[3m");
591    }
592    if modifier.contains(Modifier::UNDERLINED) {
593        out.push_str("\x1b[4m");
594    }
595    if modifier.contains(Modifier::REVERSED) {
596        out.push_str("\x1b[7m");
597    }
598    if modifier.contains(Modifier::CROSSED_OUT) {
599        out.push_str("\x1b[9m");
600    }
601}
602
603pub fn print_section(title: &str) {
604    let pad = 46usize.saturating_sub(title.len());
605    println!(
606        "\n{DIM}──{RESET} {BOLD}{title}{RESET} {DIM}{}{RESET}",
607        "─".repeat(pad)
608    );
609}
610
611#[cfg(test)]
612mod rate_limit_render_tests {
613    use super::rate_limit_message;
614    use vta_sdk::rate_limit::RateLimitSource;
615
616    fn now() -> chrono::DateTime<chrono::Utc> {
617        chrono::DateTime::parse_from_rfc3339("2026-09-16T12:00:00Z")
618            .unwrap()
619            .with_timezone(&chrono::Utc)
620    }
621
622    #[test]
623    fn a_vta_refusal_names_the_vta_the_wait_and_every_knob() {
624        let (headline, hints) = rate_limit_message(
625            RateLimitSource::Vta,
626            Some(now() + chrono::Duration::milliseconds(3_200)),
627            Some("auth"),
628            Some("https://vta.example.com/auth/challenge"),
629            now(),
630            "pnm",
631        );
632        assert_eq!(
633            headline,
634            "Rate limited by the VTA (auth) — the request was refused, not failed."
635        );
636        let all = hints.join("\n");
637        assert_eq!(hints[0], "Wait 4s before retrying.", "rounded up");
638        for needle in [
639            "Refused request: https://vta.example.com/auth/challenge",
640            "`rate_limit_interval_secs` / `rate_limit_burst`",
641            "`did_log_rate_limit_interval_secs` / `did_log_rate_limit_burst`",
642            "lower is looser",
643            "`pnm config update --rate-limit-interval-secs <N> --rate-limit-burst <N>`",
644            "`pnm config update --did-log-rate-limit-interval-secs <N> --did-log-rate-limit-burst <N>`",
645            "`trust_xff = false`",
646            "docs/02-vta/rate-limiting.md",
647        ] {
648            assert!(all.contains(needle), "missing {needle:?} in:\n{all}");
649        }
650    }
651
652    #[test]
653    fn an_unlabelled_refusal_points_at_the_proxy_and_uses_the_invoked_binary() {
654        let (headline, hints) =
655            rate_limit_message(RateLimitSource::Upstream, None, None, None, now(), "cnm");
656        assert!(headline.contains("unidentified service"), "{headline}");
657        let all = hints.join("\n");
658        assert!(all.contains("No wait was given"), "{all}");
659        assert!(all.contains("`x-rate-limit-source`"), "{all}");
660        assert!(all.contains("load balancer"), "{all}");
661        assert!(
662            !all.contains("config update"),
663            "an unattributed 429 must not send the operator to retune the VTA:\n{all}"
664        );
665    }
666
667    #[test]
668    fn a_mediator_refusal_names_the_mediator_limits_not_the_vta_config() {
669        let (headline, hints) = rate_limit_message(
670            RateLimitSource::Mediator,
671            Some(now() - chrono::Duration::seconds(5)),
672            None,
673            None,
674            now(),
675            "pnm",
676        );
677        assert!(headline.contains("the mediator"), "{headline}");
678        let all = hints.join("\n");
679        assert!(all.contains("Wait 0s"), "a stale hint is no wait: {all}");
680        assert!(all.contains("rate_limit_per_ip"), "{all}");
681        assert!(!all.contains("rate_limit_interval_secs"), "{all}");
682    }
683
684    #[test]
685    fn a_did_host_refusal_says_it_is_not_tunable_from_the_vta() {
686        let (_, hints) =
687            rate_limit_message(RateLimitSource::DidHost, None, None, None, now(), "pnm");
688        assert!(hints.join("\n").contains("not tunable from the VTA"));
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::extract_human_message;
695
696    #[test]
697    fn prefers_message_field() {
698        let body = r#"{"error":"didcomm_already_enabled","message":"DIDComm is already enabled.","mediator_did":"did:peer:2.med"}"#;
699        assert_eq!(extract_human_message(body), "DIDComm is already enabled.");
700    }
701
702    #[test]
703    fn falls_back_to_error_field_when_no_message() {
704        let body = r#"{"error":"duplicate_key"}"#;
705        assert_eq!(extract_human_message(body), "duplicate_key");
706    }
707
708    #[test]
709    fn falls_back_to_raw_text_for_non_json() {
710        let body = "plain conflict text";
711        assert_eq!(extract_human_message(body), "plain conflict text");
712    }
713
714    #[test]
715    fn falls_back_to_raw_text_when_fields_missing() {
716        // Valid JSON but neither `message` nor `error` present → raw text.
717        let body = r#"{"detail":"something"}"#;
718        assert_eq!(extract_human_message(body), body);
719    }
720}