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::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 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
375fn 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 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 other => hints.push(rl::suggested_fix(other).to_string()),
457 }
458 (headline, hints)
459}
460
461const 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
486pub 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 let body = r#"{"detail":"something"}"#;
718 assert_eq!(extract_human_message(body), body);
719 }
720}