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/// Emit a list entry as aligned `label: value` lines. Used in
60/// full-display mode where ratatui-Table truncation would hide full
61/// identifiers.
62///
63/// `pairs` is `[(label, value)]`. Labels are padded to the widest so
64/// values line up vertically. A trailing blank line separates entries.
65pub fn print_full_entry(pairs: &[(&str, &str)]) {
66    let widest = pairs.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
67    for (label, value) in pairs {
68        let pad = " ".repeat(widest.saturating_sub(label.len()));
69        println!("  {label}:{pad}  {DIM}{value}{RESET}");
70    }
71    println!();
72}
73
74/// [`print_full_entry`] for owned values. `crate::display::full_display_pairs`
75/// builds `(&'static str, String)` pairs — it has to, since a display name is
76/// computed rather than borrowed from the response — so this saves every call
77/// site the same re-borrowing dance.
78pub fn print_full_entry_owned(pairs: &[(&str, String)]) {
79    let borrowed: Vec<(&str, &str)> = pairs.iter().map(|(l, v)| (*l, v.as_str())).collect();
80    print_full_entry(&borrowed);
81}
82
83/// Print a bold section heading used above a list of full-display
84/// entries. Matches the title style of the table-mode block borders.
85pub fn print_full_list_title(title: &str, count: usize) {
86    println!();
87    println!("{BOLD}{title} ({count}){RESET}");
88    println!();
89}
90
91// ── Output format ───────────────────────────────────────────────────
92//
93// Global `--json` flag. When enabled, list commands emit a single JSON
94// document instead of the ratatui table / full-display rendering. This
95// is the automation entry point — scripts piping `pnm acl list --json`
96// into `jq` get a stable shape, while interactive operators get the
97// human-readable default.
98
99/// Output format selected by the operator.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum OutputFormat {
102    Human,
103    Json,
104}
105
106static OUTPUT_FORMAT: AtomicU8 = AtomicU8::new(0); // 0 = Human, 1 = Json
107
108/// Register the output format. Called once at CLI startup from the
109/// global `--json` flag.
110pub fn set_output_format(format: OutputFormat) {
111    OUTPUT_FORMAT.store(
112        match format {
113            OutputFormat::Human => 0,
114            OutputFormat::Json => 1,
115        },
116        Ordering::Relaxed,
117    );
118}
119
120/// Current output format. Default `Human`.
121pub fn output_format() -> OutputFormat {
122    if OUTPUT_FORMAT.load(Ordering::Relaxed) == 1 {
123        OutputFormat::Json
124    } else {
125        OutputFormat::Human
126    }
127}
128
129/// Returns true when the operator passed `--json`. List commands check
130/// this and dispatch to a JSON serializer instead of their human-
131/// readable renderer.
132#[must_use]
133pub fn is_json_output() -> bool {
134    output_format() == OutputFormat::Json
135}
136
137/// Pretty-print a serializable value as JSON to stdout. Used by list
138/// commands when [`is_json_output`] is true. Errors here are surfaced
139/// as a CLI error rather than a panic so the caller can render via
140/// `print_cli_error`.
141pub fn print_json<T: serde::Serialize>(value: &T) -> Result<(), serde_json::Error> {
142    let text = serde_json::to_string_pretty(value)?;
143    println!("{text}");
144    Ok(())
145}
146
147// ── ANSI constants ──────────────────────────────────────────────────
148
149pub const BOLD: &str = "\x1b[1m";
150pub const DIM: &str = "\x1b[2m";
151pub const GREEN: &str = "\x1b[32m";
152pub const RED: &str = "\x1b[31m";
153pub const CYAN: &str = "\x1b[36m";
154pub const YELLOW: &str = "\x1b[33m";
155pub const RESET: &str = "\x1b[0m";
156
157// ── Error reporting ─────────────────────────────────────────────────
158
159/// Print a CLI error to stderr in a form an operator can act on.
160///
161/// Downcasts to [`vta_sdk::error::VtaError`] when possible and emits a
162/// tailored remediation hint for the common failure modes (auth, network,
163/// forbidden, validation). Falls back to the raw error message + source
164/// chain for anything else, so unknown failures still get their underlying
165/// cause surfaced.
166///
167/// Call this from the top-level CLI match instead of `eprintln!("Error:
168/// {e}")` — the raw form loses auth/network context that operators need
169/// to fix things themselves.
170pub fn print_cli_error(err: &(dyn std::error::Error + 'static)) {
171    use vta_sdk::error::VtaError;
172    if let Some(vta_err) = err.downcast_ref::<VtaError>() {
173        match vta_err {
174            VtaError::Auth(msg) => {
175                eprintln!("{RED}\u{2717}{RESET} Authentication failed: {msg}");
176                eprintln!(
177                    "  {DIM}Token may be expired. Try `pnm setup` to re-authenticate, or check \
178                     that the VTA's `/auth` endpoint is reachable.{RESET}"
179                );
180            }
181            VtaError::Forbidden(msg) => {
182                eprintln!("{RED}\u{2717}{RESET} Forbidden: {msg}");
183                eprintln!(
184                    "  {DIM}Your role or context access doesn't permit this operation. \
185                     Inspect with `pnm acl get <your-did>`.{RESET}"
186                );
187            }
188            VtaError::NotFound(msg) => {
189                eprintln!("{RED}\u{2717}{RESET} Not found: {msg}");
190            }
191            VtaError::Conflict(msg) => {
192                // The SDK preserves the full 409 JSON body so programmatic
193                // callers can extract structured fields (e.g. `mediator_did`).
194                // For humans, surface the `message` (or `error`) field rather
195                // than dumping the raw JSON; fall back to the raw text for
196                // non-JSON bodies.
197                eprintln!(
198                    "{RED}\u{2717}{RESET} Conflict: {}",
199                    extract_human_message(msg)
200                );
201            }
202            VtaError::Gone(msg) => {
203                // Same treatment as the `Conflict` arm below: the SDK keeps
204                // the full 410 body, so surface the human field rather than
205                // dumping raw JSON at the operator.
206                let human = extract_human_message(msg);
207                eprintln!("{RED}\u{2717}{RESET} Resource is gone: {human}");
208                // 410 has more than one producer — the TEE bootstrap carve-out
209                // and the one-shot backup blob slots — so the carve-out
210                // walkthrough only prints when the refusal is actually about
211                // the carve-out. Unconditionally, it would send a backup
212                // operator down the admin-provisioning path for a bundle that
213                // simply expired. The marker is the server's own wording in
214                // `routes::bootstrap::carve_out_closed_error`; a miss costs
215                // the generic hint, never a wrong one.
216                if human.contains(CARVE_OUT_MARKER) {
217                    let bin = bin_name();
218                    eprintln!(
219                        "  {DIM}This usually means the bootstrap carve-out has already been \
220                         used. For a second admin, run `{bin} bootstrap provision-request` from \
221                         the new operator's host and have an existing admin run \
222                         `{bin} bootstrap provision-integration` against this VTA.{RESET}"
223                    );
224                } else {
225                    eprintln!(
226                        "  {DIM}This resource was single-use or time-limited, and has been \
227                         consumed or has expired. Retrying will not help — restart the \
228                         operation to get a fresh one.{RESET}"
229                    );
230                }
231            }
232            VtaError::Validation(msg) => {
233                eprintln!("{RED}\u{2717}{RESET} Invalid request: {msg}");
234            }
235            VtaError::Network(e) => {
236                eprintln!("{RED}\u{2717}{RESET} Network error: {e}");
237                eprintln!("  {DIM}Is the VTA reachable? Check its URL with `pnm vta info`.{RESET}");
238            }
239            VtaError::Server { status, body } => {
240                eprintln!("{RED}\u{2717}{RESET} Server error (HTTP {status}): {body}");
241                eprintln!(
242                    "  {DIM}This is a VTA-side failure. Check server logs or contact the operator.{RESET}"
243                );
244            }
245            VtaError::UnsupportedTransport(msg) => {
246                eprintln!("{RED}\u{2717}{RESET} Unsupported transport: {msg}");
247                eprintln!(
248                    "  {DIM}This operation requires a specific transport (REST or DIDComm). \
249                     Check which mode your CLI is in and whether the endpoint supports it.{RESET}"
250                );
251            }
252            VtaError::DidcommTransport(msg) => {
253                eprintln!("{RED}\u{2717}{RESET} DIDComm transport error: {msg}");
254                eprintln!(
255                    "  {DIM}Mediator or peer unreachable. Retry after checking mediator \
256                     connectivity.{RESET}"
257                );
258            }
259            VtaError::DidcommRemote { code, comment } => {
260                eprintln!("{RED}\u{2717}{RESET} Remote error ({code}): {comment}");
261            }
262            VtaError::Protocol(msg) => {
263                eprintln!("{RED}\u{2717}{RESET} Protocol error: {msg}");
264            }
265            // ── Runtime service-management variants (T0.2) ────────
266            VtaError::LastServiceRefused => {
267                let bin = bin_name();
268                eprintln!(
269                    "{RED}\u{2717}{RESET} Refused: would leave the VTA with no advertised services."
270                );
271                eprintln!(
272                    "  {DIM}At least one transport (REST or DIDComm) must remain advertised. \
273                     Enable the other transport first via `{bin} services <kind> enable …`, \
274                     then retry.{RESET}"
275                );
276            }
277            VtaError::ServiceNotPresent => {
278                let bin = bin_name();
279                eprintln!("{RED}\u{2717}{RESET} Service is not present.");
280                eprintln!(
281                    "  {DIM}The service kind isn't currently enabled. Use `{bin} services \
282                     <kind> enable …` to bring it online before updating, disabling, or rolling \
283                     it back.{RESET}"
284                );
285            }
286            VtaError::ServiceAlreadyEnabled => {
287                let bin = bin_name();
288                eprintln!("{RED}\u{2717}{RESET} Service is already enabled.");
289                eprintln!(
290                    "  {DIM}Use `{bin} services <kind> update …` to change its configuration, \
291                     or `{bin} services <kind> disable` to remove it.{RESET}"
292                );
293            }
294            VtaError::MediatorHandshakeFailed { reason } => {
295                eprintln!("{RED}\u{2717}{RESET} Mediator handshake failed: {reason}");
296                eprintln!(
297                    "  {DIM}Confirm the mediator DID is correct and the mediator is reachable. \
298                     The reason above is the specific cause from the handshake protocol.{RESET}"
299                );
300            }
301            VtaError::DrainTtlOutOfBounds {
302                min,
303                max,
304                requested,
305            } => {
306                eprintln!(
307                    "{RED}\u{2717}{RESET} Drain TTL {requested}s is outside the allowed range \
308                     [{min}s, {max}s]."
309                );
310                eprintln!(
311                    "  {DIM}Pick a value within those bounds. The minimum applies when the \
312                     command is delivered over DIDComm transport (so the listener stays up long \
313                     enough for the response).{RESET}"
314                );
315            }
316            VtaError::NoPriorMutation => {
317                let bin = bin_name();
318                eprintln!("{RED}\u{2717}{RESET} No prior mutation to roll back.");
319                eprintln!(
320                    "  {DIM}Use `{bin} services <kind> {{enable,update,disable}} …` directly \
321                     instead of rollback.{RESET}"
322                );
323            }
324            other => eprintln!("{RED}\u{2717}{RESET} Error: {other}"),
325        }
326        return;
327    }
328    eprintln!("{RED}\u{2717}{RESET} Error: {err}");
329    let mut source = err.source();
330    while let Some(s) = source {
331        eprintln!("  {DIM}caused by: {s}{RESET}");
332        source = s.source();
333    }
334}
335
336/// Pull a human-readable line out of a (possibly JSON) error body.
337///
338/// The SDK hands `VtaError::Conflict` the *raw* 409 response body so that
339/// programmatic callers can deserialize structured fields. For terminal
340/// output we don't want to dump JSON at the operator, so prefer the body's
341/// `message` field, then its `error` field, and only fall back to the raw
342/// text when the body isn't the expected JSON shape.
343/// Substring that marks a 410 as the TEE first-boot bootstrap carve-out
344/// rather than one of the other single-use resources that now render as
345/// `Gone` (the backup blob slots). Mirrors the server's wording in
346/// `vta_service::routes::bootstrap::carve_out_closed_error`.
347const CARVE_OUT_MARKER: &str = "carve-out";
348
349fn extract_human_message(body: &str) -> String {
350    serde_json::from_str::<serde_json::Value>(body)
351        .ok()
352        .and_then(|v| {
353            v.get("message")
354                .or_else(|| v.get("error"))
355                .and_then(|m| m.as_str())
356                .map(str::to_string)
357        })
358        .unwrap_or_else(|| body.to_string())
359}
360
361// ── Ratatui rendering helpers ───────────────────────────────────────
362
363pub fn print_widget(widget: impl Widget, height: u16) {
364    let width = ratatui::crossterm::terminal::size().map_or(120, |(w, _)| w);
365    let area = Rect::new(0, 0, width, height);
366    let mut buf = Buffer::empty(area);
367    widget.render(area, &mut buf);
368
369    let mut out = String::new();
370    for y in 0..height {
371        let mut cur_fg = Color::Reset;
372        let mut cur_bg = Color::Reset;
373        let mut cur_mod = Modifier::empty();
374
375        for x in 0..width {
376            let cell = &buf[(x, y)];
377            if cell.diff_option == CellDiffOption::Skip {
378                continue;
379            }
380
381            if cell.fg != cur_fg || cell.bg != cur_bg || cell.modifier != cur_mod {
382                out.push_str("\x1b[0m");
383                push_ansi_fg(&mut out, cell.fg);
384                push_ansi_bg(&mut out, cell.bg);
385                push_ansi_mod(&mut out, cell.modifier);
386                cur_fg = cell.fg;
387                cur_bg = cell.bg;
388                cur_mod = cell.modifier;
389            }
390
391            out.push_str(cell.symbol());
392        }
393        out.push_str("\x1b[0m\n");
394    }
395
396    print!("{out}");
397}
398
399pub fn push_ansi_fg(out: &mut String, color: Color) {
400    use std::fmt::Write as _;
401    match color {
402        Color::Reset => {}
403        Color::Black => out.push_str("\x1b[30m"),
404        Color::Red => out.push_str("\x1b[31m"),
405        Color::Green => out.push_str("\x1b[32m"),
406        Color::Yellow => out.push_str("\x1b[33m"),
407        Color::Blue => out.push_str("\x1b[34m"),
408        Color::Magenta => out.push_str("\x1b[35m"),
409        Color::Cyan => out.push_str("\x1b[36m"),
410        Color::Gray => out.push_str("\x1b[37m"),
411        Color::DarkGray => out.push_str("\x1b[90m"),
412        Color::LightRed => out.push_str("\x1b[91m"),
413        Color::LightGreen => out.push_str("\x1b[92m"),
414        Color::LightYellow => out.push_str("\x1b[93m"),
415        Color::LightBlue => out.push_str("\x1b[94m"),
416        Color::LightMagenta => out.push_str("\x1b[95m"),
417        Color::LightCyan => out.push_str("\x1b[96m"),
418        Color::White => out.push_str("\x1b[97m"),
419        Color::Rgb(r, g, b) => {
420            let _ = write!(out, "\x1b[38;2;{r};{g};{b}m");
421        }
422        Color::Indexed(i) => {
423            let _ = write!(out, "\x1b[38;5;{i}m");
424        }
425    }
426}
427
428pub fn push_ansi_bg(out: &mut String, color: Color) {
429    use std::fmt::Write as _;
430    match color {
431        Color::Reset => {}
432        Color::Black => out.push_str("\x1b[40m"),
433        Color::Red => out.push_str("\x1b[41m"),
434        Color::Green => out.push_str("\x1b[42m"),
435        Color::Yellow => out.push_str("\x1b[43m"),
436        Color::Blue => out.push_str("\x1b[44m"),
437        Color::Magenta => out.push_str("\x1b[45m"),
438        Color::Cyan => out.push_str("\x1b[46m"),
439        Color::Gray => out.push_str("\x1b[47m"),
440        Color::DarkGray => out.push_str("\x1b[100m"),
441        Color::LightRed => out.push_str("\x1b[101m"),
442        Color::LightGreen => out.push_str("\x1b[102m"),
443        Color::LightYellow => out.push_str("\x1b[103m"),
444        Color::LightBlue => out.push_str("\x1b[104m"),
445        Color::LightMagenta => out.push_str("\x1b[105m"),
446        Color::LightCyan => out.push_str("\x1b[106m"),
447        Color::White => out.push_str("\x1b[107m"),
448        Color::Rgb(r, g, b) => {
449            let _ = write!(out, "\x1b[48;2;{r};{g};{b}m");
450        }
451        Color::Indexed(i) => {
452            let _ = write!(out, "\x1b[48;5;{i}m");
453        }
454    }
455}
456
457pub fn push_ansi_mod(out: &mut String, modifier: Modifier) {
458    if modifier.contains(Modifier::BOLD) {
459        out.push_str("\x1b[1m");
460    }
461    if modifier.contains(Modifier::DIM) {
462        out.push_str("\x1b[2m");
463    }
464    if modifier.contains(Modifier::ITALIC) {
465        out.push_str("\x1b[3m");
466    }
467    if modifier.contains(Modifier::UNDERLINED) {
468        out.push_str("\x1b[4m");
469    }
470    if modifier.contains(Modifier::REVERSED) {
471        out.push_str("\x1b[7m");
472    }
473    if modifier.contains(Modifier::CROSSED_OUT) {
474        out.push_str("\x1b[9m");
475    }
476}
477
478pub fn print_section(title: &str) {
479    let pad = 46usize.saturating_sub(title.len());
480    println!(
481        "\n{DIM}──{RESET} {BOLD}{title}{RESET} {DIM}{}{RESET}",
482        "─".repeat(pad)
483    );
484}
485
486#[cfg(test)]
487mod tests {
488    use super::extract_human_message;
489
490    #[test]
491    fn prefers_message_field() {
492        let body = r#"{"error":"didcomm_already_enabled","message":"DIDComm is already enabled.","mediator_did":"did:peer:2.med"}"#;
493        assert_eq!(extract_human_message(body), "DIDComm is already enabled.");
494    }
495
496    #[test]
497    fn falls_back_to_error_field_when_no_message() {
498        let body = r#"{"error":"duplicate_key"}"#;
499        assert_eq!(extract_human_message(body), "duplicate_key");
500    }
501
502    #[test]
503    fn falls_back_to_raw_text_for_non_json() {
504        let body = "plain conflict text";
505        assert_eq!(extract_human_message(body), "plain conflict text");
506    }
507
508    #[test]
509    fn falls_back_to_raw_text_when_fields_missing() {
510        // Valid JSON but neither `message` nor `error` present → raw text.
511        let body = r#"{"detail":"something"}"#;
512        assert_eq!(extract_human_message(body), body);
513    }
514}