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
10static BIN_NAME: OnceLock<&'static str> = OnceLock::new();
22
23pub fn set_bin_name(name: &'static str) {
27 let _ = BIN_NAME.set(name);
28}
29
30pub fn bin_name() -> &'static str {
34 BIN_NAME.get().copied().unwrap_or("vta")
35}
36
37static FULL_DISPLAY: AtomicBool = AtomicBool::new(false);
46
47pub fn set_full_display(enabled: bool) {
50 FULL_DISPLAY.store(enabled, Ordering::Relaxed);
51}
52
53pub fn is_full_display() -> bool {
56 FULL_DISPLAY.load(Ordering::Relaxed)
57}
58
59pub 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
74pub 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
83pub fn print_full_list_title(title: &str, count: usize) {
86 println!();
87 println!("{BOLD}{title} ({count}){RESET}");
88 println!();
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum OutputFormat {
102 Human,
103 Json,
104}
105
106static OUTPUT_FORMAT: AtomicU8 = AtomicU8::new(0); pub 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
120pub fn output_format() -> OutputFormat {
122 if OUTPUT_FORMAT.load(Ordering::Relaxed) == 1 {
123 OutputFormat::Json
124 } else {
125 OutputFormat::Human
126 }
127}
128
129#[must_use]
133pub fn is_json_output() -> bool {
134 output_format() == OutputFormat::Json
135}
136
137pub 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
147pub 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
157pub 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 eprintln!(
198 "{RED}\u{2717}{RESET} Conflict: {}",
199 extract_human_message(msg)
200 );
201 }
202 VtaError::Gone(msg) => {
203 let human = extract_human_message(msg);
207 eprintln!("{RED}\u{2717}{RESET} Resource is gone: {human}");
208 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 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
336const 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
361pub 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 let body = r#"{"detail":"something"}"#;
512 assert_eq!(extract_human_message(body), body);
513 }
514}