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_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
79pub 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
94pub 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
103pub fn print_full_list_title(title: &str, count: usize) {
106 println!();
107 println!("{BOLD}{title} ({count}){RESET}");
108 println!();
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum OutputFormat {
122 Human,
123 Json,
124}
125
126static OUTPUT_FORMAT: AtomicU8 = AtomicU8::new(0); pub 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
140pub fn output_format() -> OutputFormat {
142 if OUTPUT_FORMAT.load(Ordering::Relaxed) == 1 {
143 OutputFormat::Json
144 } else {
145 OutputFormat::Human
146 }
147}
148
149#[must_use]
153pub fn is_json_output() -> bool {
154 output_format() == OutputFormat::Json
155}
156
157pub 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
167pub 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
177pub 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 eprintln!(
218 "{RED}\u{2717}{RESET} Conflict: {}",
219 extract_human_message(msg)
220 );
221 }
222 VtaError::Gone(msg) => {
223 let human = extract_human_message(msg);
227 eprintln!("{RED}\u{2717}{RESET} Resource is gone: {human}");
228 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::Network(e) => {
256 eprintln!("{RED}\u{2717}{RESET} Network error: {e}");
257 eprintln!(" {DIM}Is the VTA reachable? Check its URL with `pnm vta info`.{RESET}");
258 }
259 VtaError::Server { status, body } => {
260 eprintln!("{RED}\u{2717}{RESET} Server error (HTTP {status}): {body}");
261 eprintln!(
262 " {DIM}This is a VTA-side failure. Check server logs or contact the operator.{RESET}"
263 );
264 }
265 VtaError::UnsupportedTransport(msg) => {
266 eprintln!("{RED}\u{2717}{RESET} Unsupported transport: {msg}");
267 eprintln!(
268 " {DIM}This operation requires a specific transport (REST or DIDComm). \
269 Check which mode your CLI is in and whether the endpoint supports it.{RESET}"
270 );
271 }
272 VtaError::DidcommTransport(msg) => {
273 eprintln!("{RED}\u{2717}{RESET} DIDComm transport error: {msg}");
274 eprintln!(
275 " {DIM}Mediator or peer unreachable. Retry after checking mediator \
276 connectivity.{RESET}"
277 );
278 }
279 VtaError::DidcommRemote { code, comment } => {
280 eprintln!("{RED}\u{2717}{RESET} Remote error ({code}): {comment}");
281 }
282 VtaError::Protocol(msg) => {
283 eprintln!("{RED}\u{2717}{RESET} Protocol error: {msg}");
284 }
285 VtaError::LastServiceRefused => {
287 let bin = bin_name();
288 eprintln!(
289 "{RED}\u{2717}{RESET} Refused: would leave the VTA with no advertised services."
290 );
291 eprintln!(
292 " {DIM}At least one transport (REST or DIDComm) must remain advertised. \
293 Enable the other transport first via `{bin} services <kind> enable …`, \
294 then retry.{RESET}"
295 );
296 }
297 VtaError::ServiceNotPresent => {
298 let bin = bin_name();
299 eprintln!("{RED}\u{2717}{RESET} Service is not present.");
300 eprintln!(
301 " {DIM}The service kind isn't currently enabled. Use `{bin} services \
302 <kind> enable …` to bring it online before updating, disabling, or rolling \
303 it back.{RESET}"
304 );
305 }
306 VtaError::ServiceAlreadyEnabled => {
307 let bin = bin_name();
308 eprintln!("{RED}\u{2717}{RESET} Service is already enabled.");
309 eprintln!(
310 " {DIM}Use `{bin} services <kind> update …` to change its configuration, \
311 or `{bin} services <kind> disable` to remove it.{RESET}"
312 );
313 }
314 VtaError::MediatorHandshakeFailed { reason } => {
315 eprintln!("{RED}\u{2717}{RESET} Mediator handshake failed: {reason}");
316 eprintln!(
317 " {DIM}Confirm the mediator DID is correct and the mediator is reachable. \
318 The reason above is the specific cause from the handshake protocol.{RESET}"
319 );
320 }
321 VtaError::DrainTtlOutOfBounds {
322 min,
323 max,
324 requested,
325 } => {
326 eprintln!(
327 "{RED}\u{2717}{RESET} Drain TTL {requested}s is outside the allowed range \
328 [{min}s, {max}s]."
329 );
330 eprintln!(
331 " {DIM}Pick a value within those bounds. The minimum applies when the \
332 command is delivered over DIDComm transport (so the listener stays up long \
333 enough for the response).{RESET}"
334 );
335 }
336 VtaError::NoPriorMutation => {
337 let bin = bin_name();
338 eprintln!("{RED}\u{2717}{RESET} No prior mutation to roll back.");
339 eprintln!(
340 " {DIM}Use `{bin} services <kind> {{enable,update,disable}} …` directly \
341 instead of rollback.{RESET}"
342 );
343 }
344 other => eprintln!("{RED}\u{2717}{RESET} Error: {other}"),
345 }
346 return;
347 }
348 eprintln!("{RED}\u{2717}{RESET} Error: {err}");
349 let mut source = err.source();
350 while let Some(s) = source {
351 eprintln!(" {DIM}caused by: {s}{RESET}");
352 source = s.source();
353 }
354}
355
356const CARVE_OUT_MARKER: &str = "carve-out";
368
369fn extract_human_message(body: &str) -> String {
370 serde_json::from_str::<serde_json::Value>(body)
371 .ok()
372 .and_then(|v| {
373 v.get("message")
374 .or_else(|| v.get("error"))
375 .and_then(|m| m.as_str())
376 .map(str::to_string)
377 })
378 .unwrap_or_else(|| body.to_string())
379}
380
381pub fn print_widget(widget: impl Widget, height: u16) {
384 let width = ratatui::crossterm::terminal::size().map_or(120, |(w, _)| w);
385 let area = Rect::new(0, 0, width, height);
386 let mut buf = Buffer::empty(area);
387 widget.render(area, &mut buf);
388
389 let mut out = String::new();
390 for y in 0..height {
391 let mut cur_fg = Color::Reset;
392 let mut cur_bg = Color::Reset;
393 let mut cur_mod = Modifier::empty();
394
395 for x in 0..width {
396 let cell = &buf[(x, y)];
397 if cell.diff_option == CellDiffOption::Skip {
398 continue;
399 }
400
401 if cell.fg != cur_fg || cell.bg != cur_bg || cell.modifier != cur_mod {
402 out.push_str("\x1b[0m");
403 push_ansi_fg(&mut out, cell.fg);
404 push_ansi_bg(&mut out, cell.bg);
405 push_ansi_mod(&mut out, cell.modifier);
406 cur_fg = cell.fg;
407 cur_bg = cell.bg;
408 cur_mod = cell.modifier;
409 }
410
411 out.push_str(cell.symbol());
412 }
413 out.push_str("\x1b[0m\n");
414 }
415
416 print!("{out}");
417}
418
419pub fn push_ansi_fg(out: &mut String, color: Color) {
420 use std::fmt::Write as _;
421 match color {
422 Color::Reset => {}
423 Color::Black => out.push_str("\x1b[30m"),
424 Color::Red => out.push_str("\x1b[31m"),
425 Color::Green => out.push_str("\x1b[32m"),
426 Color::Yellow => out.push_str("\x1b[33m"),
427 Color::Blue => out.push_str("\x1b[34m"),
428 Color::Magenta => out.push_str("\x1b[35m"),
429 Color::Cyan => out.push_str("\x1b[36m"),
430 Color::Gray => out.push_str("\x1b[37m"),
431 Color::DarkGray => out.push_str("\x1b[90m"),
432 Color::LightRed => out.push_str("\x1b[91m"),
433 Color::LightGreen => out.push_str("\x1b[92m"),
434 Color::LightYellow => out.push_str("\x1b[93m"),
435 Color::LightBlue => out.push_str("\x1b[94m"),
436 Color::LightMagenta => out.push_str("\x1b[95m"),
437 Color::LightCyan => out.push_str("\x1b[96m"),
438 Color::White => out.push_str("\x1b[97m"),
439 Color::Rgb(r, g, b) => {
440 let _ = write!(out, "\x1b[38;2;{r};{g};{b}m");
441 }
442 Color::Indexed(i) => {
443 let _ = write!(out, "\x1b[38;5;{i}m");
444 }
445 }
446}
447
448pub fn push_ansi_bg(out: &mut String, color: Color) {
449 use std::fmt::Write as _;
450 match color {
451 Color::Reset => {}
452 Color::Black => out.push_str("\x1b[40m"),
453 Color::Red => out.push_str("\x1b[41m"),
454 Color::Green => out.push_str("\x1b[42m"),
455 Color::Yellow => out.push_str("\x1b[43m"),
456 Color::Blue => out.push_str("\x1b[44m"),
457 Color::Magenta => out.push_str("\x1b[45m"),
458 Color::Cyan => out.push_str("\x1b[46m"),
459 Color::Gray => out.push_str("\x1b[47m"),
460 Color::DarkGray => out.push_str("\x1b[100m"),
461 Color::LightRed => out.push_str("\x1b[101m"),
462 Color::LightGreen => out.push_str("\x1b[102m"),
463 Color::LightYellow => out.push_str("\x1b[103m"),
464 Color::LightBlue => out.push_str("\x1b[104m"),
465 Color::LightMagenta => out.push_str("\x1b[105m"),
466 Color::LightCyan => out.push_str("\x1b[106m"),
467 Color::White => out.push_str("\x1b[107m"),
468 Color::Rgb(r, g, b) => {
469 let _ = write!(out, "\x1b[48;2;{r};{g};{b}m");
470 }
471 Color::Indexed(i) => {
472 let _ = write!(out, "\x1b[48;5;{i}m");
473 }
474 }
475}
476
477pub fn push_ansi_mod(out: &mut String, modifier: Modifier) {
478 if modifier.contains(Modifier::BOLD) {
479 out.push_str("\x1b[1m");
480 }
481 if modifier.contains(Modifier::DIM) {
482 out.push_str("\x1b[2m");
483 }
484 if modifier.contains(Modifier::ITALIC) {
485 out.push_str("\x1b[3m");
486 }
487 if modifier.contains(Modifier::UNDERLINED) {
488 out.push_str("\x1b[4m");
489 }
490 if modifier.contains(Modifier::REVERSED) {
491 out.push_str("\x1b[7m");
492 }
493 if modifier.contains(Modifier::CROSSED_OUT) {
494 out.push_str("\x1b[9m");
495 }
496}
497
498pub fn print_section(title: &str) {
499 let pad = 46usize.saturating_sub(title.len());
500 println!(
501 "\n{DIM}──{RESET} {BOLD}{title}{RESET} {DIM}{}{RESET}",
502 "─".repeat(pad)
503 );
504}
505
506#[cfg(test)]
507mod tests {
508 use super::extract_human_message;
509
510 #[test]
511 fn prefers_message_field() {
512 let body = r#"{"error":"didcomm_already_enabled","message":"DIDComm is already enabled.","mediator_did":"did:peer:2.med"}"#;
513 assert_eq!(extract_human_message(body), "DIDComm is already enabled.");
514 }
515
516 #[test]
517 fn falls_back_to_error_field_when_no_message() {
518 let body = r#"{"error":"duplicate_key"}"#;
519 assert_eq!(extract_human_message(body), "duplicate_key");
520 }
521
522 #[test]
523 fn falls_back_to_raw_text_for_non_json() {
524 let body = "plain conflict text";
525 assert_eq!(extract_human_message(body), "plain conflict text");
526 }
527
528 #[test]
529 fn falls_back_to_raw_text_when_fields_missing() {
530 let body = r#"{"detail":"something"}"#;
532 assert_eq!(extract_human_message(body), body);
533 }
534}