Skip to main content

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}