vta_cli_common/display.rs
1//! Terminal rendering for DIDs and their display names.
2//!
3//! The naming logic itself lives in [`vta_sdk::display_name`] — this module is
4//! the thin CLI layer on top: ANSI colour, ratatui cells, and the decision
5//! about whether a table gets a NAME column at all.
6//!
7//! # Usage
8//!
9//! A command builds a [`NameBook`] from the response it already has, then
10//! renders through it:
11//!
12//! ```ignore
13//! let mut book = NameBook::new();
14//! book_from_acl(&mut book, &resp.entries); // one pass, no extra requests
15//! let show_names = book.names_any(resp.entries.iter().map(|e| e.did.as_str()));
16//! ```
17//!
18//! The `book_from_*` helpers are the payoff: filling the book from an ACL
19//! listing also names the `Created By` column, because a granting admin is
20//! very often another entry's subject. No lookup, no round trip.
21
22use std::sync::atomic::{AtomicBool, Ordering};
23
24use ratatui::{
25 style::{Color, Style},
26 widgets::Cell,
27};
28
29pub use vta_sdk::display_name::{DisplayName, NameBook, NameSource, shorten_did};
30
31use crate::render::{DIM, RESET, YELLOW};
32
33/// Column heading for the name column. One constant so every table agrees.
34pub const NAME_HEADER: &str = "Name";
35
36/// Placeholder for a row with no name, used only when *some* other row in the
37/// same table has one.
38pub const UNNAMED: &str = "\u{2014}";
39
40/// A DID rendered for a fixed-width table cell: shortened, dimmed.
41#[must_use]
42pub fn did_cell(did: &str) -> Cell<'static> {
43 Cell::from(shorten_did(did)).style(Style::default().fg(Color::DarkGray))
44}
45
46/// A name cell for `did`.
47///
48/// Unverified agent names are yellow — see [`vta_sdk::display_name`] for why
49/// they must stay visually distinct from a name the operator can rely on.
50#[must_use]
51pub fn name_cell(book: &NameBook, did: &str) -> Cell<'static> {
52 match book.get(did) {
53 Some(n) if n.is_trusted() => Cell::from(n.name.clone()),
54 Some(_) => Cell::from(book.name_of(did).unwrap_or_default())
55 .style(Style::default().fg(Color::Yellow)),
56 None => Cell::from(UNNAMED).style(Style::default().fg(Color::DarkGray)),
57 }
58}
59
60/// A cell for a DID that may carry a name — used where a column holds a
61/// *reference* to some other principal (`Created By`, `Actor`, a sender)
62/// rather than the row's own subject, so there is no room for a paired name
63/// column. Falls back to the shortened DID.
64#[must_use]
65pub fn named_did_cell(book: &NameBook, did: &str) -> Cell<'static> {
66 match book.name_of(did) {
67 Some(name) => {
68 let style = if book.get(did).is_some_and(DisplayName::is_trusted) {
69 Style::default()
70 } else {
71 Style::default().fg(Color::Yellow)
72 };
73 Cell::from(name).style(style)
74 }
75 None => did_cell(did),
76 }
77}
78
79/// Plain-text name-or-DID for a fixed-width column.
80///
81/// No ANSI: these go through `{:<60}`-style padding, which counts escape
82/// bytes as width and would wreck the alignment.
83#[must_use]
84pub fn name_or_did(book: &NameBook, did: &str) -> String {
85 book.name_of(did).unwrap_or_else(|| shorten_did(did))
86}
87
88/// One-line rendering for prose, confirmations and progress messages:
89/// `mediator-prod (did:webvh:QmXk…9f2:example.com)`.
90///
91/// Colours the name when it is an unverified claim; leaves it plain
92/// otherwise. Falls back to the shortened DID alone.
93#[must_use]
94pub fn inline(book: &NameBook, did: &str) -> String {
95 match book.name_of(did) {
96 Some(name) => {
97 let short = shorten_did(did);
98 if book.get(did).is_some_and(DisplayName::is_trusted) {
99 format!("{name} {DIM}({short}){RESET}")
100 } else {
101 format!("{YELLOW}{name}{RESET} {DIM}({short}){RESET}")
102 }
103 }
104 None => shorten_did(did),
105 }
106}
107
108/// Key-value pairs for a `--full-display` block: a `Name` line *above* the
109/// `DID` line, and the DID in full — abbreviating it is the one thing full
110/// display exists to avoid.
111///
112/// Returns just the DID pair when the DID has no name, so unnamed entries
113/// don't grow a `Name: —` line.
114#[must_use]
115pub fn full_display_pairs(book: &NameBook, did: &str) -> Vec<(&'static str, String)> {
116 match book.name_of(did) {
117 Some(name) => vec![("Name", name), ("DID", did.to_string())],
118 None => vec![("DID", did.to_string())],
119 }
120}
121
122// ── Agent-name resolution toggle ────────────────────────────────────
123//
124// Global `--resolve-agent-names`. Off by default: a verified lookup is a DID
125// resolution plus an outbound HTTPS fetch per claimed name, per DID on
126// screen, against hosts we do not control. On a fifty-row `acl list` that is
127// fifty fan-outs, so the operator asks for it rather than paying for it by
128// accident.
129
130static RESOLVE_AGENT_NAMES: AtomicBool = AtomicBool::new(false);
131
132/// Register the operator's `--resolve-agent-names` choice. Called once at
133/// CLI startup.
134pub fn set_resolve_agent_names(enabled: bool) {
135 RESOLVE_AGENT_NAMES.store(enabled, Ordering::Relaxed);
136}
137
138/// Whether agent-name resolution was requested.
139#[must_use]
140pub fn resolve_agent_names() -> bool {
141 RESOLVE_AGENT_NAMES.load(Ordering::Relaxed)
142}
143
144/// Add verified agent names to `book` for each DID in `dids`.
145///
146/// No-op unless `--resolve-agent-names` was passed. Each name is
147/// round-tripped before being trusted (see
148/// [`vta_sdk::display_name::agent_name`]); a claim that does not lead back to
149/// its DID still lands in the book, but as `AgentName { verified: false }`,
150/// which ranks below every local label and renders tagged.
151///
152/// Failures are swallowed per DID — an unreachable name server degrades that
153/// row to its local name or bare DID rather than failing the command.
154#[cfg(feature = "agent-names")]
155pub async fn resolve_agent_names_into<'a>(
156 book: &mut NameBook,
157 dids: impl IntoIterator<Item = &'a str>,
158) {
159 if !resolve_agent_names() {
160 return;
161 }
162 vta_sdk::display_name::agent_name::fill_book(book, dids).await;
163}
164
165/// Without the `agent-names` feature there is nothing to resolve.
166#[cfg(not(feature = "agent-names"))]
167pub async fn resolve_agent_names_into<'a>(
168 _book: &mut NameBook,
169 _dids: impl IntoIterator<Item = &'a str>,
170) {
171}
172
173// ── Book builders ───────────────────────────────────────────────────
174//
175// Each takes a response the command already fetched. Adding one here is
176// preferable to naming DIDs at the call site, so that every command that
177// touches the same response type gets the same names.
178
179/// Fill `book` from an ACL listing.
180///
181/// Names every entry's subject from its `label`. The `created_by` DIDs are
182/// *not* inserted — they carry no label of their own — but they resolve
183/// anyway whenever the granting admin also holds an ACL entry, which is the
184/// common case.
185pub fn book_from_acl(book: &mut NameBook, entries: &[vta_sdk::client::AclEntryResponse]) {
186 for entry in entries {
187 book.insert_opt(&entry.did, entry.label.as_deref(), NameSource::AclLabel);
188 }
189}
190
191/// Fill `book` from a context listing: each context's `name` names its DID.
192pub fn book_from_contexts(book: &mut NameBook, contexts: &[vta_sdk::client::ContextResponse]) {
193 for ctx in contexts {
194 if let Some(did) = &ctx.did {
195 book.insert(did, DisplayName::new(&ctx.name, NameSource::ContextName));
196 }
197 }
198}
199
200/// Best-effort book from the two listings that name DIDs on a VTA: the ACL
201/// (subject labels) and the contexts (each context's name, for its own DID).
202///
203/// For surfaces whose own response carries no name — hosted `did:webvh`
204/// records, sealed-bundle banners, mediator tables. Both fetches are
205/// swallowed on failure: naming is decoration, and a caller who may read
206/// their own DIDs but not the ACL must still get their output.
207pub async fn book_from_vta(client: &vta_sdk::client::VtaClient) -> NameBook {
208 let mut book = NameBook::new();
209 if let Ok(acl) = client.list_acl(None).await {
210 book_from_acl(&mut book, &acl.entries);
211 }
212 if let Ok(ctxs) = client.list_contexts().await {
213 book_from_contexts(&mut book, &ctxs.contexts);
214 }
215 book
216}
217
218/// Fill `book` from a webvh hosting-server listing.
219pub fn book_from_webvh_servers(book: &mut NameBook, servers: &[vta_sdk::webvh::WebvhServerRecord]) {
220 for s in servers {
221 book.insert_opt(&s.did, s.label.as_deref(), NameSource::ServerLabel);
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 const DID: &str = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
230
231 #[test]
232 fn full_display_keeps_the_did_whole() {
233 let mut book = NameBook::new();
234 book.insert(DID, DisplayName::new("ops", NameSource::AclLabel));
235 let pairs = full_display_pairs(&book, DID);
236 assert_eq!(pairs[0], ("Name", "ops".to_string()));
237 assert_eq!(
238 pairs[1],
239 ("DID", DID.to_string()),
240 "full display must never abbreviate — that is what it is for"
241 );
242 }
243
244 #[test]
245 fn unnamed_entries_get_no_name_line() {
246 let book = NameBook::new();
247 let pairs = full_display_pairs(&book, DID);
248 assert_eq!(pairs.len(), 1);
249 assert_eq!(pairs[0].0, "DID");
250 }
251
252 #[test]
253 fn inline_marks_an_unverified_claim() {
254 let mut book = NameBook::new();
255 book.insert(
256 DID,
257 DisplayName::new(
258 "mybank.com/@treasury",
259 NameSource::AgentName { verified: false },
260 ),
261 );
262 let out = inline(&book, DID);
263 assert!(out.contains("unverified"));
264 assert!(out.contains(YELLOW), "an unchecked claim must stand out");
265 }
266
267 #[test]
268 fn inline_falls_back_to_the_shortened_did() {
269 let book = NameBook::new();
270 assert_eq!(inline(&book, DID), shorten_did(DID));
271 }
272}