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 ratatui::{
23    style::{Color, Style},
24    widgets::Cell,
25};
26
27pub use vta_sdk::display_name::{DisplayName, NameBook, NameSource, shorten_did};
28
29use crate::render::{DIM, RESET, YELLOW};
30
31/// Column heading for the name column. One constant so every table agrees.
32pub const NAME_HEADER: &str = "Name";
33
34/// Placeholder for a row with no name, used only when *some* other row in the
35/// same table has one.
36pub const UNNAMED: &str = "\u{2014}";
37
38/// A DID rendered for a fixed-width table cell: shortened, dimmed.
39#[must_use]
40pub fn did_cell(did: &str) -> Cell<'static> {
41    Cell::from(shorten_did(did)).style(Style::default().fg(Color::DarkGray))
42}
43
44/// A name cell for `did`.
45///
46/// Unverified agent names are yellow — see [`vta_sdk::display_name`] for why
47/// they must stay visually distinct from a name the operator can rely on.
48#[must_use]
49pub fn name_cell(book: &NameBook, did: &str) -> Cell<'static> {
50    match book.get(did) {
51        Some(n) if n.is_trusted() => Cell::from(n.name.clone()),
52        Some(_) => Cell::from(book.name_of(did).unwrap_or_default())
53            .style(Style::default().fg(Color::Yellow)),
54        None => Cell::from(UNNAMED).style(Style::default().fg(Color::DarkGray)),
55    }
56}
57
58/// A cell for a DID that may carry a name — used where a column holds a
59/// *reference* to some other principal (`Created By`, `Actor`, a sender)
60/// rather than the row's own subject, so there is no room for a paired name
61/// column. Falls back to the shortened DID.
62#[must_use]
63pub fn named_did_cell(book: &NameBook, did: &str) -> Cell<'static> {
64    match book.name_of(did) {
65        Some(name) => {
66            let style = if book.get(did).is_some_and(DisplayName::is_trusted) {
67                Style::default()
68            } else {
69                Style::default().fg(Color::Yellow)
70            };
71            Cell::from(name).style(style)
72        }
73        None => did_cell(did),
74    }
75}
76
77/// Plain-text name-or-DID for a fixed-width column.
78///
79/// No ANSI: these go through `{:<60}`-style padding, which counts escape
80/// bytes as width and would wreck the alignment.
81#[must_use]
82pub fn name_or_did(book: &NameBook, did: &str) -> String {
83    book.name_of(did).unwrap_or_else(|| shorten_did(did))
84}
85
86/// One-line rendering for prose, confirmations and progress messages:
87/// `mediator-prod (did:webvh:QmXk…9f2:example.com)`.
88///
89/// Colours the name when it is an unverified claim; leaves it plain
90/// otherwise. Falls back to the shortened DID alone.
91#[must_use]
92pub fn inline(book: &NameBook, did: &str) -> String {
93    match book.name_of(did) {
94        Some(name) => {
95            let short = shorten_did(did);
96            if book.get(did).is_some_and(DisplayName::is_trusted) {
97                format!("{name} {DIM}({short}){RESET}")
98            } else {
99                format!("{YELLOW}{name}{RESET} {DIM}({short}){RESET}")
100            }
101        }
102        None => shorten_did(did),
103    }
104}
105
106/// Key-value pairs for a `--full-display` block: a `Name` line *above* the
107/// `DID` line, and the DID in full — abbreviating it is the one thing full
108/// display exists to avoid.
109///
110/// Returns just the DID pair when the DID has no name, so unnamed entries
111/// don't grow a `Name: —` line.
112#[must_use]
113pub fn full_display_pairs(book: &NameBook, did: &str) -> Vec<(&'static str, String)> {
114    match book.name_of(did) {
115        Some(name) => vec![("Name", name), ("DID", did.to_string())],
116        None => vec![("DID", did.to_string())],
117    }
118}
119
120// ── Book builders ───────────────────────────────────────────────────
121//
122// Each takes a response the command already fetched. Adding one here is
123// preferable to naming DIDs at the call site, so that every command that
124// touches the same response type gets the same names.
125
126/// Fill `book` from an ACL listing.
127///
128/// Names every entry's subject from its `label`. The `created_by` DIDs are
129/// *not* inserted — they carry no label of their own — but they resolve
130/// anyway whenever the granting admin also holds an ACL entry, which is the
131/// common case.
132pub fn book_from_acl(book: &mut NameBook, entries: &[vta_sdk::client::AclEntryResponse]) {
133    for entry in entries {
134        book.insert_opt(&entry.did, entry.label.as_deref(), NameSource::AclLabel);
135    }
136}
137
138/// Fill `book` from a context listing: each context's `name` names its DID.
139pub fn book_from_contexts(book: &mut NameBook, contexts: &[vta_sdk::contexts::ContextRecord]) {
140    for ctx in contexts {
141        if let Some(did) = &ctx.did {
142            book.insert(did, DisplayName::new(&ctx.name, NameSource::ContextName));
143        }
144    }
145}
146
147/// Fill `book` from a webvh hosting-server listing.
148pub fn book_from_webvh_servers(book: &mut NameBook, servers: &[vta_sdk::webvh::WebvhServerRecord]) {
149    for s in servers {
150        book.insert_opt(&s.did, s.label.as_deref(), NameSource::ServerLabel);
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    const DID: &str = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
159
160    #[test]
161    fn full_display_keeps_the_did_whole() {
162        let mut book = NameBook::new();
163        book.insert(DID, DisplayName::new("ops", NameSource::AclLabel));
164        let pairs = full_display_pairs(&book, DID);
165        assert_eq!(pairs[0], ("Name", "ops".to_string()));
166        assert_eq!(
167            pairs[1],
168            ("DID", DID.to_string()),
169            "full display must never abbreviate — that is what it is for"
170        );
171    }
172
173    #[test]
174    fn unnamed_entries_get_no_name_line() {
175        let book = NameBook::new();
176        let pairs = full_display_pairs(&book, DID);
177        assert_eq!(pairs.len(), 1);
178        assert_eq!(pairs[0].0, "DID");
179    }
180
181    #[test]
182    fn inline_marks_an_unverified_claim() {
183        let mut book = NameBook::new();
184        book.insert(
185            DID,
186            DisplayName::new(
187                "mybank.com/@treasury",
188                NameSource::AgentName { verified: false },
189            ),
190        );
191        let out = inline(&book, DID);
192        assert!(out.contains("unverified"));
193        assert!(out.contains(YELLOW), "an unchecked claim must stand out");
194    }
195
196    #[test]
197    fn inline_falls_back_to_the_shortened_did() {
198        let book = NameBook::new();
199        assert_eq!(inline(&book, DID), shorten_did(DID));
200    }
201}