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 a bold section heading used above a list of full-display
75/// entries. Matches the title style of the table-mode block borders.
76pub fn print_full_list_title(title: &str, count: usize) {
77    println!();
78    println!("{BOLD}{title} ({count}){RESET}");
79    println!();
80}
81
82// ── Output format ───────────────────────────────────────────────────
83//
84// Global `--json` flag. When enabled, list commands emit a single JSON
85// document instead of the ratatui table / full-display rendering. This
86// is the automation entry point — scripts piping `pnm acl list --json`
87// into `jq` get a stable shape, while interactive operators get the
88// human-readable default.
89
90/// Output format selected by the operator.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum OutputFormat {
93    Human,
94    Json,
95}
96
97static OUTPUT_FORMAT: AtomicU8 = AtomicU8::new(0); // 0 = Human, 1 = Json
98
99/// Register the output format. Called once at CLI startup from the
100/// global `--json` flag.
101pub fn set_output_format(format: OutputFormat) {
102    OUTPUT_FORMAT.store(
103        match format {
104            OutputFormat::Human => 0,
105            OutputFormat::Json => 1,
106        },
107        Ordering::Relaxed,
108    );
109}
110
111/// Current output format. Default `Human`.
112pub fn output_format() -> OutputFormat {
113    if OUTPUT_FORMAT.load(Ordering::Relaxed) == 1 {
114        OutputFormat::Json
115    } else {
116        OutputFormat::Human
117    }
118}
119
120/// Returns true when the operator passed `--json`. List commands check
121/// this and dispatch to a JSON serializer instead of their human-
122/// readable renderer.
123#[must_use]
124pub fn is_json_output() -> bool {
125    output_format() == OutputFormat::Json
126}
127
128/// Pretty-print a serializable value as JSON to stdout. Used by list
129/// commands when [`is_json_output`] is true. Errors here are surfaced
130/// as a CLI error rather than a panic so the caller can render via
131/// `print_cli_error`.
132pub fn print_json<T: serde::Serialize>(value: &T) -> Result<(), serde_json::Error> {
133    let text = serde_json::to_string_pretty(value)?;
134    println!("{text}");
135    Ok(())
136}
137
138// ── ANSI constants ──────────────────────────────────────────────────
139
140pub const BOLD: &str = "\x1b[1m";
141pub const DIM: &str = "\x1b[2m";
142pub const GREEN: &str = "\x1b[32m";
143pub const RED: &str = "\x1b[31m";
144pub const CYAN: &str = "\x1b[36m";
145pub const YELLOW: &str = "\x1b[33m";
146pub const RESET: &str = "\x1b[0m";
147
148// ── Error reporting ─────────────────────────────────────────────────
149
150/// Print a CLI error to stderr in a form an operator can act on.
151///
152/// Downcasts to [`vta_sdk::error::VtaError`] when possible and emits a
153/// tailored remediation hint for the common failure modes (auth, network,
154/// forbidden, validation). Falls back to the raw error message + source
155/// chain for anything else, so unknown failures still get their underlying
156/// cause surfaced.
157///
158/// Call this from the top-level CLI match instead of `eprintln!("Error:
159/// {e}")` — the raw form loses auth/network context that operators need
160/// to fix things themselves.
161pub fn print_cli_error(err: &(dyn std::error::Error + 'static)) {
162    use vta_sdk::error::VtaError;
163    if let Some(vta_err) = err.downcast_ref::<VtaError>() {
164        match vta_err {
165            VtaError::Auth(msg) => {
166                eprintln!("{RED}\u{2717}{RESET} Authentication failed: {msg}");
167                eprintln!(
168                    "  {DIM}Token may be expired. Try `pnm setup` to re-authenticate, or check \
169                     that the VTA's `/auth` endpoint is reachable.{RESET}"
170                );
171            }
172            VtaError::Forbidden(msg) => {
173                eprintln!("{RED}\u{2717}{RESET} Forbidden: {msg}");
174                eprintln!(
175                    "  {DIM}Your role or context access doesn't permit this operation. \
176                     Inspect with `pnm acl get <your-did>`.{RESET}"
177                );
178            }
179            VtaError::NotFound(msg) => {
180                eprintln!("{RED}\u{2717}{RESET} Not found: {msg}");
181            }
182            VtaError::Conflict(msg) => {
183                eprintln!("{RED}\u{2717}{RESET} Conflict: {msg}");
184            }
185            VtaError::Gone(msg) => {
186                let bin = bin_name();
187                eprintln!("{RED}\u{2717}{RESET} Resource is gone: {msg}");
188                eprintln!(
189                    "  {DIM}This usually means the bootstrap carve-out has already been used. \
190                     For a second admin, run `{bin} bootstrap provision-request` from the new \
191                     operator's host and have an existing admin run \
192                     `{bin} bootstrap provision-integration` against this VTA.{RESET}"
193                );
194            }
195            VtaError::Validation(msg) => {
196                eprintln!("{RED}\u{2717}{RESET} Invalid request: {msg}");
197            }
198            VtaError::Network(e) => {
199                eprintln!("{RED}\u{2717}{RESET} Network error: {e}");
200                eprintln!("  {DIM}Is the VTA reachable? Check its URL with `pnm vta info`.{RESET}");
201            }
202            VtaError::Server { status, body } => {
203                eprintln!("{RED}\u{2717}{RESET} Server error (HTTP {status}): {body}");
204                eprintln!(
205                    "  {DIM}This is a VTA-side failure. Check server logs or contact the operator.{RESET}"
206                );
207            }
208            VtaError::UnsupportedTransport(msg) => {
209                eprintln!("{RED}\u{2717}{RESET} Unsupported transport: {msg}");
210                eprintln!(
211                    "  {DIM}This operation requires a specific transport (REST or DIDComm). \
212                     Check which mode your CLI is in and whether the endpoint supports it.{RESET}"
213                );
214            }
215            VtaError::DidcommTransport(msg) => {
216                eprintln!("{RED}\u{2717}{RESET} DIDComm transport error: {msg}");
217                eprintln!(
218                    "  {DIM}Mediator or peer unreachable. Retry after checking mediator \
219                     connectivity.{RESET}"
220                );
221            }
222            VtaError::DidcommRemote { code, comment } => {
223                eprintln!("{RED}\u{2717}{RESET} Remote error ({code}): {comment}");
224            }
225            VtaError::Protocol(msg) => {
226                eprintln!("{RED}\u{2717}{RESET} Protocol error: {msg}");
227            }
228            // ── Runtime service-management variants (T0.2) ────────
229            VtaError::LastServiceRefused => {
230                let bin = bin_name();
231                eprintln!(
232                    "{RED}\u{2717}{RESET} Refused: would leave the VTA with no advertised services."
233                );
234                eprintln!(
235                    "  {DIM}At least one transport (REST or DIDComm) must remain advertised. \
236                     Enable the other transport first via `{bin} services <kind> enable …`, \
237                     then retry.{RESET}"
238                );
239            }
240            VtaError::ServiceNotPresent => {
241                let bin = bin_name();
242                eprintln!("{RED}\u{2717}{RESET} Service is not present.");
243                eprintln!(
244                    "  {DIM}The service kind isn't currently enabled. Use `{bin} services \
245                     <kind> enable …` to bring it online before updating, disabling, or rolling \
246                     it back.{RESET}"
247                );
248            }
249            VtaError::ServiceAlreadyEnabled => {
250                let bin = bin_name();
251                eprintln!("{RED}\u{2717}{RESET} Service is already enabled.");
252                eprintln!(
253                    "  {DIM}Use `{bin} services <kind> update …` to change its configuration, \
254                     or `{bin} services <kind> disable` to remove it.{RESET}"
255                );
256            }
257            VtaError::MediatorHandshakeFailed { reason } => {
258                eprintln!("{RED}\u{2717}{RESET} Mediator handshake failed: {reason}");
259                eprintln!(
260                    "  {DIM}Confirm the mediator DID is correct and the mediator is reachable. \
261                     The reason above is the specific cause from the handshake protocol.{RESET}"
262                );
263            }
264            VtaError::DrainTtlOutOfBounds {
265                min,
266                max,
267                requested,
268            } => {
269                eprintln!(
270                    "{RED}\u{2717}{RESET} Drain TTL {requested}s is outside the allowed range \
271                     [{min}s, {max}s]."
272                );
273                eprintln!(
274                    "  {DIM}Pick a value within those bounds. The minimum applies when the \
275                     command is delivered over DIDComm transport (so the listener stays up long \
276                     enough for the response).{RESET}"
277                );
278            }
279            VtaError::NoPriorMutation => {
280                let bin = bin_name();
281                eprintln!("{RED}\u{2717}{RESET} No prior mutation to roll back.");
282                eprintln!(
283                    "  {DIM}Use `{bin} services <kind> {{enable,update,disable}} …` directly \
284                     instead of rollback.{RESET}"
285                );
286            }
287            other => eprintln!("{RED}\u{2717}{RESET} Error: {other}"),
288        }
289        return;
290    }
291    eprintln!("{RED}\u{2717}{RESET} Error: {err}");
292    let mut source = err.source();
293    while let Some(s) = source {
294        eprintln!("  {DIM}caused by: {s}{RESET}");
295        source = s.source();
296    }
297}
298
299// ── Ratatui rendering helpers ───────────────────────────────────────
300
301pub fn print_widget(widget: impl Widget, height: u16) {
302    let width = ratatui::crossterm::terminal::size().map_or(120, |(w, _)| w);
303    let area = Rect::new(0, 0, width, height);
304    let mut buf = Buffer::empty(area);
305    widget.render(area, &mut buf);
306
307    let mut out = String::new();
308    for y in 0..height {
309        let mut cur_fg = Color::Reset;
310        let mut cur_bg = Color::Reset;
311        let mut cur_mod = Modifier::empty();
312
313        for x in 0..width {
314            let cell = &buf[(x, y)];
315            if cell.diff_option == CellDiffOption::Skip {
316                continue;
317            }
318
319            if cell.fg != cur_fg || cell.bg != cur_bg || cell.modifier != cur_mod {
320                out.push_str("\x1b[0m");
321                push_ansi_fg(&mut out, cell.fg);
322                push_ansi_bg(&mut out, cell.bg);
323                push_ansi_mod(&mut out, cell.modifier);
324                cur_fg = cell.fg;
325                cur_bg = cell.bg;
326                cur_mod = cell.modifier;
327            }
328
329            out.push_str(cell.symbol());
330        }
331        out.push_str("\x1b[0m\n");
332    }
333
334    print!("{out}");
335}
336
337pub fn push_ansi_fg(out: &mut String, color: Color) {
338    use std::fmt::Write as _;
339    match color {
340        Color::Reset => {}
341        Color::Black => out.push_str("\x1b[30m"),
342        Color::Red => out.push_str("\x1b[31m"),
343        Color::Green => out.push_str("\x1b[32m"),
344        Color::Yellow => out.push_str("\x1b[33m"),
345        Color::Blue => out.push_str("\x1b[34m"),
346        Color::Magenta => out.push_str("\x1b[35m"),
347        Color::Cyan => out.push_str("\x1b[36m"),
348        Color::Gray => out.push_str("\x1b[37m"),
349        Color::DarkGray => out.push_str("\x1b[90m"),
350        Color::LightRed => out.push_str("\x1b[91m"),
351        Color::LightGreen => out.push_str("\x1b[92m"),
352        Color::LightYellow => out.push_str("\x1b[93m"),
353        Color::LightBlue => out.push_str("\x1b[94m"),
354        Color::LightMagenta => out.push_str("\x1b[95m"),
355        Color::LightCyan => out.push_str("\x1b[96m"),
356        Color::White => out.push_str("\x1b[97m"),
357        Color::Rgb(r, g, b) => {
358            let _ = write!(out, "\x1b[38;2;{r};{g};{b}m");
359        }
360        Color::Indexed(i) => {
361            let _ = write!(out, "\x1b[38;5;{i}m");
362        }
363    }
364}
365
366pub fn push_ansi_bg(out: &mut String, color: Color) {
367    use std::fmt::Write as _;
368    match color {
369        Color::Reset => {}
370        Color::Black => out.push_str("\x1b[40m"),
371        Color::Red => out.push_str("\x1b[41m"),
372        Color::Green => out.push_str("\x1b[42m"),
373        Color::Yellow => out.push_str("\x1b[43m"),
374        Color::Blue => out.push_str("\x1b[44m"),
375        Color::Magenta => out.push_str("\x1b[45m"),
376        Color::Cyan => out.push_str("\x1b[46m"),
377        Color::Gray => out.push_str("\x1b[47m"),
378        Color::DarkGray => out.push_str("\x1b[100m"),
379        Color::LightRed => out.push_str("\x1b[101m"),
380        Color::LightGreen => out.push_str("\x1b[102m"),
381        Color::LightYellow => out.push_str("\x1b[103m"),
382        Color::LightBlue => out.push_str("\x1b[104m"),
383        Color::LightMagenta => out.push_str("\x1b[105m"),
384        Color::LightCyan => out.push_str("\x1b[106m"),
385        Color::White => out.push_str("\x1b[107m"),
386        Color::Rgb(r, g, b) => {
387            let _ = write!(out, "\x1b[48;2;{r};{g};{b}m");
388        }
389        Color::Indexed(i) => {
390            let _ = write!(out, "\x1b[48;5;{i}m");
391        }
392    }
393}
394
395pub fn push_ansi_mod(out: &mut String, modifier: Modifier) {
396    if modifier.contains(Modifier::BOLD) {
397        out.push_str("\x1b[1m");
398    }
399    if modifier.contains(Modifier::DIM) {
400        out.push_str("\x1b[2m");
401    }
402    if modifier.contains(Modifier::ITALIC) {
403        out.push_str("\x1b[3m");
404    }
405    if modifier.contains(Modifier::UNDERLINED) {
406        out.push_str("\x1b[4m");
407    }
408    if modifier.contains(Modifier::REVERSED) {
409        out.push_str("\x1b[7m");
410    }
411    if modifier.contains(Modifier::CROSSED_OUT) {
412        out.push_str("\x1b[9m");
413    }
414}
415
416pub fn print_section(title: &str) {
417    let pad = 46usize.saturating_sub(title.len());
418    println!(
419        "\n{DIM}──{RESET} {BOLD}{title}{RESET} {DIM}{}{RESET}",
420        "─".repeat(pad)
421    );
422}