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 fn print_json_or_exit<T: serde::Serialize>(value: &T, what: &str) {
175 if let Err(e) = print_json(value) {
176 eprintln!("Error serializing {what}: {e}");
177 std::process::exit(1);
178 }
179}
180
181static COLOR: AtomicBool = AtomicBool::new(true);
191
192pub fn set_color(enabled: bool) {
195 COLOR.store(enabled, Ordering::Relaxed);
196}
197
198#[must_use]
201pub fn color_enabled() -> bool {
202 COLOR.load(Ordering::Relaxed)
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub struct Ansi(&'static str);
214
215impl Ansi {
216 #[must_use]
218 pub const fn code(self) -> &'static str {
219 self.0
220 }
221}
222
223impl std::fmt::Display for Ansi {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 if color_enabled() {
226 f.write_str(self.0)
227 } else {
228 Ok(())
229 }
230 }
231}
232
233pub const BOLD: Ansi = Ansi("\x1b[1m");
234pub const DIM: Ansi = Ansi("\x1b[2m");
235pub const GREEN: Ansi = Ansi("\x1b[32m");
236pub const RED: Ansi = Ansi("\x1b[31m");
237pub const CYAN: Ansi = Ansi("\x1b[36m");
238pub const YELLOW: Ansi = Ansi("\x1b[33m");
239pub const RESET: Ansi = Ansi("\x1b[0m");
240
241pub fn print_cli_error(err: &(dyn std::error::Error + 'static)) {
255 use vta_sdk::error::VtaError;
256 if let Some(vta_err) = err.downcast_ref::<VtaError>() {
257 match vta_err {
258 VtaError::Auth(msg) => {
259 eprintln!("{RED}\u{2717}{RESET} Authentication failed: {msg}");
260 eprintln!(
261 " {DIM}Token may be expired. Try `pnm setup` to re-authenticate, or check \
262 that the VTA's `/auth` endpoint is reachable.{RESET}"
263 );
264 }
265 VtaError::Forbidden(msg) => {
266 eprintln!("{RED}\u{2717}{RESET} Forbidden: {msg}");
267 eprintln!(
268 " {DIM}Your role or context access doesn't permit this operation. \
269 Inspect with `pnm acl get <your-did>`.{RESET}"
270 );
271 }
272 VtaError::NotFound(msg) => {
273 eprintln!("{RED}\u{2717}{RESET} Not found: {msg}");
274 }
275 VtaError::Conflict(msg) => {
276 eprintln!(
282 "{RED}\u{2717}{RESET} Conflict: {}",
283 extract_human_message(msg)
284 );
285 }
286 VtaError::Gone(msg) => {
287 let human = extract_human_message(msg);
291 eprintln!("{RED}\u{2717}{RESET} Resource is gone: {human}");
292 if human.contains(CARVE_OUT_MARKER) {
301 let bin = bin_name();
302 eprintln!(
303 " {DIM}This usually means the bootstrap carve-out has already been \
304 used. For a second admin, run `{bin} bootstrap provision-request` from \
305 the new operator's host and have an existing admin run \
306 `{bin} bootstrap provision-integration` against this VTA.{RESET}"
307 );
308 } else {
309 eprintln!(
310 " {DIM}This resource was single-use or time-limited, and has been \
311 consumed or has expired. Retrying will not help — restart the \
312 operation to get a fresh one.{RESET}"
313 );
314 }
315 }
316 VtaError::Validation(msg) => {
317 eprintln!("{RED}\u{2717}{RESET} Invalid request: {msg}");
318 }
319 VtaError::RateLimited {
320 limited_by,
321 retry_after,
322 limiter,
323 url,
324 } => {
325 let (headline, hints) = rate_limit_message(
326 *limited_by,
327 *retry_after,
328 limiter.as_deref(),
329 url.as_deref(),
330 chrono::Utc::now(),
331 bin_name(),
332 );
333 eprintln!("{RED}\u{2717}{RESET} {headline}");
334 for hint in hints {
335 eprintln!(" {DIM}{hint}{RESET}");
336 }
337 }
338 VtaError::Network(e) => {
339 eprintln!("{RED}\u{2717}{RESET} Network error: {e}");
340 eprintln!(" {DIM}Is the VTA reachable? Check its URL with `pnm vta info`.{RESET}");
341 }
342 VtaError::Server { status, body } => {
343 eprintln!("{RED}\u{2717}{RESET} Server error (HTTP {status}): {body}");
344 eprintln!(
345 " {DIM}This is a VTA-side failure. Check server logs or contact the operator.{RESET}"
346 );
347 }
348 VtaError::UnsupportedTransport(msg) => {
349 eprintln!("{RED}\u{2717}{RESET} Unsupported transport: {msg}");
350 eprintln!(
351 " {DIM}This operation requires a specific transport (REST or DIDComm). \
352 Check which mode your CLI is in and whether the endpoint supports it.{RESET}"
353 );
354 }
355 VtaError::DidcommTransport(msg) => {
356 eprintln!("{RED}\u{2717}{RESET} DIDComm transport error: {msg}");
357 eprintln!(
358 " {DIM}Mediator or peer unreachable. Retry after checking mediator \
359 connectivity.{RESET}"
360 );
361 }
362 VtaError::DidcommRemote { code, comment } => {
363 eprintln!("{RED}\u{2717}{RESET} Remote error ({code}): {comment}");
364 }
365 VtaError::Protocol(msg) => {
366 eprintln!("{RED}\u{2717}{RESET} Protocol error: {msg}");
367 }
368 VtaError::Rejected { code, message, .. } => {
369 eprintln!("{RED}\u{2717}{RESET} Refused [{code}]: {message}");
370 }
371 VtaError::LastServiceRefused => {
373 let bin = bin_name();
374 eprintln!(
375 "{RED}\u{2717}{RESET} Refused: would leave the VTA with no advertised services."
376 );
377 eprintln!(
378 " {DIM}At least one transport (REST or DIDComm) must remain advertised. \
379 Enable the other transport first via `{bin} services <kind> enable …`, \
380 then retry.{RESET}"
381 );
382 }
383 VtaError::ServiceNotPresent => {
384 let bin = bin_name();
385 eprintln!("{RED}\u{2717}{RESET} Service is not present.");
386 eprintln!(
387 " {DIM}The service kind isn't currently enabled. Use `{bin} services \
388 <kind> enable …` to bring it online before updating, disabling, or rolling \
389 it back.{RESET}"
390 );
391 }
392 VtaError::ServiceAlreadyEnabled => {
393 let bin = bin_name();
394 eprintln!("{RED}\u{2717}{RESET} Service is already enabled.");
395 eprintln!(
396 " {DIM}Use `{bin} services <kind> update …` to change its configuration, \
397 or `{bin} services <kind> disable` to remove it.{RESET}"
398 );
399 }
400 VtaError::MediatorHandshakeFailed { reason } => {
401 eprintln!("{RED}\u{2717}{RESET} Mediator handshake failed: {reason}");
402 eprintln!(
403 " {DIM}Confirm the mediator DID is correct and the mediator is reachable. \
404 The reason above is the specific cause from the handshake protocol.{RESET}"
405 );
406 }
407 VtaError::DrainTtlOutOfBounds {
408 min,
409 max,
410 requested,
411 } => {
412 eprintln!(
413 "{RED}\u{2717}{RESET} Drain TTL {requested}s is outside the allowed range \
414 [{min}s, {max}s]."
415 );
416 eprintln!(
417 " {DIM}Pick a value within those bounds. The minimum applies when the \
418 command is delivered over DIDComm transport (so the listener stays up long \
419 enough for the response).{RESET}"
420 );
421 }
422 VtaError::NoMatchingProtocol {
423 counterparty_did,
424 ours,
425 theirs,
426 } => {
427 let (headline, hints) =
428 no_matching_protocol_message(counterparty_did, ours, theirs, bin_name());
429 eprintln!("{RED}\u{2717}{RESET} {headline}");
430 for hint in hints {
431 eprintln!(" {DIM}{hint}{RESET}");
432 }
433 }
434 VtaError::NoPriorMutation => {
435 let bin = bin_name();
436 eprintln!("{RED}\u{2717}{RESET} No prior mutation to roll back.");
437 eprintln!(
438 " {DIM}Use `{bin} services <kind> {{enable,update,disable}} …` directly \
439 instead of rollback.{RESET}"
440 );
441 }
442 other => eprintln!("{RED}\u{2717}{RESET} Error: {other}"),
443 }
444 return;
445 }
446 eprintln!("{RED}\u{2717}{RESET} Error: {err}");
447 let mut source = err.source();
448 while let Some(s) = source {
449 eprintln!(" {DIM}caused by: {s}{RESET}");
450 source = s.source();
451 }
452}
453
454fn no_matching_protocol_message(
465 peer: &str,
466 ours: &[vta_sdk::protocol::matching::Protocol],
467 theirs: &[vta_sdk::protocol::matching::Protocol],
468 bin: &str,
469) -> (String, Vec<String>) {
470 use vta_sdk::protocol::matching::Protocol;
471 let list = |set: &[Protocol]| {
472 if set.is_empty() {
473 "nothing".to_string()
474 } else {
475 set.iter()
476 .map(|p| p.as_str())
477 .collect::<Vec<_>>()
478 .join(", ")
479 }
480 };
481 let headline = format!(
482 "No transport in common with `{peer}`: it advertises [{}], and the sender can start [{}].",
483 list(theirs),
484 list(ours)
485 );
486 let mut hints = Vec::new();
487 if theirs.is_empty() {
488 hints.push(format!(
489 "`{peer}` advertises no TSP, DIDComm or Trust-Task HTTPS service in its DID \
490 document; nobody can reach it until it publishes one."
491 ));
492 return (headline, hints);
493 }
494 if !theirs.contains(&Protocol::Rest) {
495 hints.push(format!(
496 "Ask `{peer}`'s operator to publish a Trust-Task HTTPS service (`TrustTaskHTTPS`) \
497 in its DID document. It carries any signed document, including one signed as a \
498 room persona, which TSP and DIDComm cannot carry for a VTA."
499 ));
500 }
501 if theirs.contains(&Protocol::Didcomm) && !ours.contains(&Protocol::Didcomm) {
502 hints.push(format!(
503 "If the sending VTA has no DIDComm session, `{bin} services didcomm enable \
504 --mediator-did <did>` gives it one. That does not help a document signed as a \
505 room persona: DIDComm authenticates the VTA's own DID."
506 ));
507 }
508 if theirs.contains(&Protocol::Tsp) && !ours.contains(&Protocol::Tsp) {
509 hints.push(format!(
510 "If the sending VTA has no TSP session, `{bin} services tsp enable \
511 --mediator-did <did>` gives it one (same caveat for a persona-signed document)."
512 ));
513 }
514 (headline, hints)
515}
516
517fn rate_limit_message(
526 limited_by: vta_sdk::rate_limit::RateLimitSource,
527 retry_after: Option<chrono::DateTime<chrono::Utc>>,
528 limiter: Option<&str>,
529 url: Option<&str>,
530 now: chrono::DateTime<chrono::Utc>,
531 bin: &str,
532) -> (String, Vec<String>) {
533 use vta_sdk::rate_limit::{self as rl, RateLimitSource};
534
535 let scope = limiter;
536 let limiter = limiter.map(|l| format!(" ({l})")).unwrap_or_default();
537 let headline = format!(
538 "Rate limited by {}{limiter} — the request was refused, not failed.",
539 limited_by.label()
540 );
541
542 let mut hints = Vec::new();
543 hints.push(match retry_after {
544 Some(at) => {
545 let ms = (at - now).num_milliseconds().max(0);
548 let secs = (ms + 999) / 1000;
549 format!("Wait {secs}s before retrying.")
550 }
551 None => "No wait was given; wait a few seconds before retrying.".to_string(),
552 });
553 if let Some(url) = url {
554 hints.push(format!("Refused request: {url}"));
555 }
556
557 match limited_by {
558 RateLimitSource::Vta if scope == Some(rl::OPERATIONS_SUBJECT_LIMITER) => {
561 hints.push(
562 "This client has used its own share of the VTA's operation budget; other \
563 clients are not affected. Retry after the wait, or spread the work out."
564 .to_string(),
565 );
566 hints.push(format!(
567 "The share is `[server] {}` (operations per second, per client DID); on a TEE \
568 VTA it comes from the tenant overlay's `server` block. See {}.",
569 rl::VTA_OPERATION_PER_SUBJECT_KEY,
570 rl::VTA_DOCS,
571 ));
572 }
573 RateLimitSource::Vta if scope == Some(rl::OPERATIONS_LIMITER) => {
576 hints.push(
577 "The VTA is at its operation budget: the whole appliance is at capacity, not \
578 only this client. Retry after the wait."
579 .to_string(),
580 );
581 hints.push(format!(
582 "The budget is `[server] {}` / `{}` (operations per second), sized for the \
583 instance; on a TEE VTA it comes from the tenant overlay's `server` block. \
584 Raise it only with a larger instance. See {}.",
585 rl::VTA_OPERATION_RATE_KEY,
586 rl::VTA_OPERATION_BURST_KEY,
587 rl::VTA_DOCS,
588 ));
589 }
590 RateLimitSource::Vta => {
591 hints.push(format!(
592 "To loosen it, in the VTA's `[server]` config: `{}` / `{}` for the auth and \
593 bootstrap endpoints, `{}` / `{}` for the VTA's own did.jsonl. Intervals are \
594 seconds per token, so lower is looser.",
595 rl::VTA_INTERVAL_KEY,
596 rl::VTA_BURST_KEY,
597 rl::VTA_DID_LOG_INTERVAL_KEY,
598 rl::VTA_DID_LOG_BURST_KEY,
599 ));
600 hints.push(format!(
601 "At runtime: `{bin} {}`, or `{bin} {}`.",
602 rl::VTA_RUNTIME_FLAGS,
603 rl::VTA_DID_LOG_RUNTIME_FLAGS,
604 ));
605 hints.push(format!(
606 "Behind a reverse proxy with `{} = false`, every client shares one bucket. \
607 See {}.",
608 rl::VTA_TRUST_XFF_KEY,
609 rl::VTA_DOCS,
610 ));
611 }
612 RateLimitSource::Mediator => hints.push(format!(
613 "This is the DIDComm/TSP mediator, not the VTA. Its operator tunes {} — requests \
614 per second, so higher is looser.",
615 rl::MEDIATOR_KEYS,
616 )),
617 RateLimitSource::Upstream => {
618 hints.push(format!(
619 "The response carried no `{}` header, so nothing says who sent it: a reverse \
620 proxy or load balancer in front of the VTA, or a VTA older than that header.",
621 rl::SOURCE_HEADER,
622 ));
623 hints.push(format!(
624 "Check the proxy / load balancer's limits and logs, or upgrade the VTA so its \
625 own refusals are labelled. See {}.",
626 rl::VTA_DOCS,
627 ));
628 }
629 other => hints.push(rl::suggested_fix(other).to_string()),
632 }
633 (headline, hints)
634}
635
636const CARVE_OUT_MARKER: &str = "carve-out";
648
649fn extract_human_message(body: &str) -> String {
650 serde_json::from_str::<serde_json::Value>(body)
651 .ok()
652 .and_then(|v| {
653 v.get("message")
654 .or_else(|| v.get("error"))
655 .and_then(|m| m.as_str())
656 .map(str::to_string)
657 })
658 .unwrap_or_else(|| body.to_string())
659}
660
661pub fn print_widget(widget: impl Widget, height: u16) {
664 print!("{}", render_widget(widget, height, color_enabled()));
665}
666
667fn render_widget(widget: impl Widget, height: u16, color: bool) -> String {
673 let width = ratatui::crossterm::terminal::size().map_or(120, |(w, _)| w);
674 let area = Rect::new(0, 0, width, height);
675 let mut buf = Buffer::empty(area);
676 widget.render(area, &mut buf);
677
678 let mut out = String::new();
679 for y in 0..height {
680 let mut cur_fg = Color::Reset;
681 let mut cur_bg = Color::Reset;
682 let mut cur_mod = Modifier::empty();
683 let mut line = String::new();
684
685 for x in 0..width {
686 let cell = &buf[(x, y)];
687 if cell.diff_option == CellDiffOption::Skip {
688 continue;
689 }
690
691 if color && (cell.fg != cur_fg || cell.bg != cur_bg || cell.modifier != cur_mod) {
692 line.push_str("\x1b[0m");
693 push_ansi_fg(&mut line, cell.fg);
694 push_ansi_bg(&mut line, cell.bg);
695 push_ansi_mod(&mut line, cell.modifier);
696 cur_fg = cell.fg;
697 cur_bg = cell.bg;
698 cur_mod = cell.modifier;
699 }
700
701 line.push_str(cell.symbol());
702 }
703
704 if color {
705 out.push_str(&line);
706 out.push_str("\x1b[0m\n");
707 } else {
708 out.push_str(line.trim_end());
711 out.push('\n');
712 }
713 }
714
715 out
716}
717
718pub fn push_ansi_fg(out: &mut String, color: Color) {
719 use std::fmt::Write as _;
720 match color {
721 Color::Reset => {}
722 Color::Black => out.push_str("\x1b[30m"),
723 Color::Red => out.push_str("\x1b[31m"),
724 Color::Green => out.push_str("\x1b[32m"),
725 Color::Yellow => out.push_str("\x1b[33m"),
726 Color::Blue => out.push_str("\x1b[34m"),
727 Color::Magenta => out.push_str("\x1b[35m"),
728 Color::Cyan => out.push_str("\x1b[36m"),
729 Color::Gray => out.push_str("\x1b[37m"),
730 Color::DarkGray => out.push_str("\x1b[90m"),
731 Color::LightRed => out.push_str("\x1b[91m"),
732 Color::LightGreen => out.push_str("\x1b[92m"),
733 Color::LightYellow => out.push_str("\x1b[93m"),
734 Color::LightBlue => out.push_str("\x1b[94m"),
735 Color::LightMagenta => out.push_str("\x1b[95m"),
736 Color::LightCyan => out.push_str("\x1b[96m"),
737 Color::White => out.push_str("\x1b[97m"),
738 Color::Rgb(r, g, b) => {
739 let _ = write!(out, "\x1b[38;2;{r};{g};{b}m");
740 }
741 Color::Indexed(i) => {
742 let _ = write!(out, "\x1b[38;5;{i}m");
743 }
744 }
745}
746
747pub fn push_ansi_bg(out: &mut String, color: Color) {
748 use std::fmt::Write as _;
749 match color {
750 Color::Reset => {}
751 Color::Black => out.push_str("\x1b[40m"),
752 Color::Red => out.push_str("\x1b[41m"),
753 Color::Green => out.push_str("\x1b[42m"),
754 Color::Yellow => out.push_str("\x1b[43m"),
755 Color::Blue => out.push_str("\x1b[44m"),
756 Color::Magenta => out.push_str("\x1b[45m"),
757 Color::Cyan => out.push_str("\x1b[46m"),
758 Color::Gray => out.push_str("\x1b[47m"),
759 Color::DarkGray => out.push_str("\x1b[100m"),
760 Color::LightRed => out.push_str("\x1b[101m"),
761 Color::LightGreen => out.push_str("\x1b[102m"),
762 Color::LightYellow => out.push_str("\x1b[103m"),
763 Color::LightBlue => out.push_str("\x1b[104m"),
764 Color::LightMagenta => out.push_str("\x1b[105m"),
765 Color::LightCyan => out.push_str("\x1b[106m"),
766 Color::White => out.push_str("\x1b[107m"),
767 Color::Rgb(r, g, b) => {
768 let _ = write!(out, "\x1b[48;2;{r};{g};{b}m");
769 }
770 Color::Indexed(i) => {
771 let _ = write!(out, "\x1b[48;5;{i}m");
772 }
773 }
774}
775
776pub fn push_ansi_mod(out: &mut String, modifier: Modifier) {
777 if modifier.contains(Modifier::BOLD) {
778 out.push_str("\x1b[1m");
779 }
780 if modifier.contains(Modifier::DIM) {
781 out.push_str("\x1b[2m");
782 }
783 if modifier.contains(Modifier::ITALIC) {
784 out.push_str("\x1b[3m");
785 }
786 if modifier.contains(Modifier::UNDERLINED) {
787 out.push_str("\x1b[4m");
788 }
789 if modifier.contains(Modifier::REVERSED) {
790 out.push_str("\x1b[7m");
791 }
792 if modifier.contains(Modifier::CROSSED_OUT) {
793 out.push_str("\x1b[9m");
794 }
795}
796
797pub fn print_section(title: &str) {
798 let pad = 46usize.saturating_sub(title.len());
799 println!(
800 "\n{DIM}──{RESET} {BOLD}{title}{RESET} {DIM}{}{RESET}",
801 "─".repeat(pad)
802 );
803}
804
805#[cfg(test)]
806mod rate_limit_render_tests {
807 use super::rate_limit_message;
808 use vta_sdk::rate_limit::RateLimitSource;
809
810 fn now() -> chrono::DateTime<chrono::Utc> {
811 chrono::DateTime::parse_from_rfc3339("2026-09-16T12:00:00Z")
812 .unwrap()
813 .with_timezone(&chrono::Utc)
814 }
815
816 #[test]
817 fn a_vta_refusal_names_the_vta_the_wait_and_every_knob() {
818 let (headline, hints) = rate_limit_message(
819 RateLimitSource::Vta,
820 Some(now() + chrono::Duration::milliseconds(3_200)),
821 Some("auth"),
822 Some("https://vta.example.com/auth/challenge"),
823 now(),
824 "pnm",
825 );
826 assert_eq!(
827 headline,
828 "Rate limited by the VTA (auth) — the request was refused, not failed."
829 );
830 let all = hints.join("\n");
831 assert_eq!(hints[0], "Wait 4s before retrying.", "rounded up");
832 for needle in [
833 "Refused request: https://vta.example.com/auth/challenge",
834 "`rate_limit_interval_secs` / `rate_limit_burst`",
835 "`did_log_rate_limit_interval_secs` / `did_log_rate_limit_burst`",
836 "lower is looser",
837 "`pnm config update --rate-limit-interval-secs <N> --rate-limit-burst <N>`",
838 "`pnm config update --did-log-rate-limit-interval-secs <N> --did-log-rate-limit-burst <N>`",
839 "`trust_xff = false`",
840 "docs/02-vta/rate-limiting.md",
841 ] {
842 assert!(all.contains(needle), "missing {needle:?} in:\n{all}");
843 }
844 }
845
846 #[test]
847 fn an_operation_budget_refusal_names_the_budget_not_the_per_ip_knobs() {
848 let (headline, hints) = rate_limit_message(
849 RateLimitSource::Vta,
850 Some(now() + chrono::Duration::seconds(1)),
851 Some("operations"),
852 None,
853 now(),
854 "pnm",
855 );
856 assert!(headline.contains("(operations)"), "{headline}");
857 let all = hints.join("\n");
858 assert!(
859 all.contains("`[server] operation_rate_limit_per_sec`"),
860 "{all}"
861 );
862 assert!(all.contains("tenant overlay"), "{all}");
863 assert!(
864 !all.contains("config update"),
865 "the budget is not runtime-patchable, so no `config update` hint:\n{all}"
866 );
867 }
868
869 #[test]
870 fn a_per_client_refusal_names_the_clients_share() {
871 let (_, hints) = rate_limit_message(
872 RateLimitSource::Vta,
873 None,
874 Some("operations-subject"),
875 None,
876 now(),
877 "pnm",
878 );
879 let all = hints.join("\n");
880 assert!(all.contains("its own share"), "{all}");
881 assert!(
882 all.contains("`[server] operation_rate_limit_per_subject_per_sec`"),
883 "{all}"
884 );
885 assert!(!all.contains("config update"), "{all}");
886 }
887
888 #[test]
889 fn an_unlabelled_refusal_points_at_the_proxy_and_uses_the_invoked_binary() {
890 let (headline, hints) =
891 rate_limit_message(RateLimitSource::Upstream, None, None, None, now(), "cnm");
892 assert!(headline.contains("unidentified service"), "{headline}");
893 let all = hints.join("\n");
894 assert!(all.contains("No wait was given"), "{all}");
895 assert!(all.contains("`x-rate-limit-source`"), "{all}");
896 assert!(all.contains("load balancer"), "{all}");
897 assert!(
898 !all.contains("config update"),
899 "an unattributed 429 must not send the operator to retune the VTA:\n{all}"
900 );
901 }
902
903 #[test]
904 fn a_mediator_refusal_names_the_mediator_limits_not_the_vta_config() {
905 let (headline, hints) = rate_limit_message(
906 RateLimitSource::Mediator,
907 Some(now() - chrono::Duration::seconds(5)),
908 None,
909 None,
910 now(),
911 "pnm",
912 );
913 assert!(headline.contains("the mediator"), "{headline}");
914 let all = hints.join("\n");
915 assert!(all.contains("Wait 0s"), "a stale hint is no wait: {all}");
916 assert!(all.contains("rate_limit_per_ip"), "{all}");
917 assert!(!all.contains("rate_limit_interval_secs"), "{all}");
918 }
919
920 #[test]
921 fn a_did_host_refusal_says_it_is_not_tunable_from_the_vta() {
922 let (_, hints) =
923 rate_limit_message(RateLimitSource::DidHost, None, None, None, now(), "pnm");
924 assert!(hints.join("\n").contains("not tunable from the VTA"));
925 }
926}
927
928#[cfg(test)]
929mod tests {
930 use super::{extract_human_message, no_matching_protocol_message, render_widget};
931
932 #[test]
936 fn no_matching_protocol_names_both_sets_and_the_missing_service() {
937 use vta_sdk::protocol::matching::Protocol;
938 let (headline, hints) = no_matching_protocol_message(
939 "did:webvh:vtc",
940 &[Protocol::Rest],
941 &[Protocol::Didcomm],
942 "pnm",
943 );
944 assert!(
945 headline.contains("advertises [didcomm]") && headline.contains("start [rest]"),
946 "{headline}"
947 );
948 let all = hints.join("\n");
949 assert!(all.contains("TrustTaskHTTPS"), "{all}");
950 assert!(all.contains("pnm services didcomm enable"), "{all}");
951 assert!(!all.contains("services tsp"), "TSP is not on offer: {all}");
952 }
953
954 #[test]
955 fn no_matching_protocol_with_nothing_advertised_says_so() {
956 let (headline, hints) = no_matching_protocol_message("did:example:p", &[], &[], "pnm");
957 assert!(headline.contains("advertises [nothing]"), "{headline}");
958 assert_eq!(hints.len(), 1);
959 assert!(hints[0].contains("no TSP, DIDComm or Trust-Task HTTPS"));
960 }
961
962 #[test]
963 fn prefers_message_field() {
964 let body = r#"{"error":"didcomm_already_enabled","message":"DIDComm is already enabled.","mediator_did":"did:peer:2.med"}"#;
965 assert_eq!(extract_human_message(body), "DIDComm is already enabled.");
966 }
967
968 #[test]
969 fn falls_back_to_error_field_when_no_message() {
970 let body = r#"{"error":"duplicate_key"}"#;
971 assert_eq!(extract_human_message(body), "duplicate_key");
972 }
973
974 #[test]
975 fn falls_back_to_raw_text_for_non_json() {
976 let body = "plain conflict text";
977 assert_eq!(extract_human_message(body), "plain conflict text");
978 }
979
980 #[test]
981 fn falls_back_to_raw_text_when_fields_missing() {
982 let body = r#"{"detail":"something"}"#;
984 assert_eq!(extract_human_message(body), body);
985 }
986
987 use ratatui::style::{Color, Style};
990 use ratatui::widgets::Paragraph;
991
992 fn coloured_line() -> Paragraph<'static> {
993 Paragraph::new("hello").style(Style::default().fg(Color::Red))
994 }
995
996 #[test]
997 fn test_widget_colour_enabled_emits_escapes() {
998 let out = render_widget(coloured_line(), 1, true);
999 assert!(
1000 out.contains('\u{1b}'),
1001 "expected escape sequences with colour on, got {out:?}"
1002 );
1003 }
1004
1005 #[test]
1006 fn test_widget_colour_disabled_emits_none() {
1007 let out = render_widget(coloured_line(), 1, false);
1008 assert!(
1009 !out.contains('\u{1b}'),
1010 "no escape may reach a non-terminal, got {out:?}"
1011 );
1012 assert!(out.contains("hello"), "the text itself must survive");
1013 }
1014
1015 #[test]
1016 fn test_widget_colour_disabled_trims_row_padding() {
1017 let out = render_widget(coloured_line(), 1, false);
1018 for line in out.lines() {
1019 assert_eq!(
1020 line.trim_end(),
1021 line,
1022 "rows are padded to terminal width; uncoloured that is trailing blanks"
1023 );
1024 }
1025 }
1026}