use serde_json::Value;
use vta_sdk::client::VtaClient;
use vta_sdk::protocols::persona::{
ContactDocument, LocalProfileEntry, ProfileEntry, Provenance, ValueType,
};
use crate::render::{BOLD, CYAN, DIM, RED, RESET, YELLOW, is_json_output, print_json};
type CmdResult = Result<(), Box<dyn std::error::Error>>;
fn print_result(label: &str, value: &Value) -> CmdResult {
if is_json_output() {
print_json(value)?;
} else {
println!("{label}");
println!("{}", serde_json::to_string_pretty(value)?);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_attribute_put(
client: &VtaClient,
claim_type: String,
value: Value,
value_type: ValueType,
provenance: Provenance,
label: Option<String>,
attribute_id: Option<String>,
expected_version: Option<u64>,
) -> CmdResult {
let result = client
.persona_attribute_put(
&claim_type,
value,
value_type,
provenance,
label.as_deref(),
attribute_id.as_deref(),
expected_version,
)
.await?;
print_result("Attribute:", &result)
}
pub async fn cmd_attribute_list(
client: &VtaClient,
type_prefix: Option<String>,
include_values: bool,
include_sensitive: bool,
include_stale: Option<bool>,
limit: Option<std::num::NonZeroU64>,
cursor: Option<String>,
) -> CmdResult {
let result = client
.persona_attribute_list(
type_prefix.as_deref(),
include_values,
include_sensitive,
include_stale,
limit,
cursor.as_deref(),
)
.await?;
if !is_json_output() {
if !include_values {
println!("{DIM}Names only — add --values to include the values themselves.{RESET}");
} else if !include_sensitive {
println!(
"{DIM}Sensitive values — cards, passports, phone numbers — are held back. \
Add --sensitive to include them.{RESET}"
);
}
}
print_result("Your attributes:", &result)
}
pub async fn cmd_attribute_delete(
client: &VtaClient,
attribute_id: String,
cascade: bool,
expected_version: Option<u64>,
) -> CmdResult {
let result = client
.persona_attribute_delete(&attribute_id, cascade, expected_version)
.await?;
if !is_json_output() && cascade {
println!(
"{DIM}Cascaded: {attribute_id} was taken off every face that showed it. Nothing \
already shared is affected — that has left.{RESET}"
);
}
print_result("Result:", &result)
}
pub async fn cmd_attribute_purge_version(
client: &VtaClient,
attribute_id: String,
versions: Vec<u64>,
) -> CmdResult {
let out = client
.persona_attribute_purge_version(
&attribute_id,
(!versions.is_empty()).then_some(versions.as_slice()),
)
.await?;
let result = serde_json::to_value(&out)?;
if !is_json_output() {
let stale = out.stale_pins.len();
if out.purged.is_empty() {
println!("{DIM}Nothing to remove — no kept version of {attribute_id} matched.{RESET}");
} else if stale > 0 {
println!(
"{YELLOW}{stale} face(s) pinned what was removed, and now show nothing for it. \
Repin or edit them if they should show something.{RESET}"
);
}
println!(
"{DIM}Removed from your agent. Anyone already shown the old value keeps it — \
`persona disclosure history` says who.{RESET}"
);
}
print_result("Result:", &result)
}
pub async fn cmd_profile_put(
client: &VtaClient,
name: String,
entries: Vec<ProfileEntry>,
credential_refs: Vec<String>,
profile_id: Option<String>,
expected_version: Option<u64>,
) -> CmdResult {
let result = client
.persona_profile_put(
&name,
entries,
credential_refs,
profile_id.as_deref(),
expected_version,
)
.await?;
print_result("Face:", &result)
}
pub async fn cmd_profile_get(client: &VtaClient, profile_id: String, resolve: bool) -> CmdResult {
let result = client.persona_profile_get(&profile_id, resolve).await?;
if !is_json_output() && !resolve {
println!(
"{DIM}Showing how the face is built — add --resolve to see the attributes it would \
show.{RESET}"
);
}
print_result("Face:", &result)
}
pub async fn cmd_profile_list(
client: &VtaClient,
limit: Option<std::num::NonZeroU64>,
cursor: Option<String>,
) -> CmdResult {
let result = client
.persona_profile_list(limit, cursor.as_deref())
.await?;
print_result("Faces:", &result)
}
pub async fn cmd_profile_delete(
client: &VtaClient,
profile_id: String,
unbind: bool,
expected_version: Option<u64>,
) -> CmdResult {
let result = client
.persona_profile_delete(&profile_id, unbind, expected_version)
.await?;
if !is_json_output() && unbind {
println!(
"{YELLOW}Taken off: every persona wearing {profile_id} now shows nothing until \
it wears another. Nothing already shared is affected — that has left.{RESET}"
);
}
print_result("Result:", &result)
}
pub async fn cmd_binding_set(
client: &VtaClient,
context_id: String,
persona_did: String,
profile_id: Option<String>,
public_entries: Vec<String>,
label: Option<String>,
expected_version: Option<u64>,
) -> CmdResult {
let clearing = profile_id.is_none();
let unlabelled = !clearing && label.is_none();
let result = client
.persona_binding_set(
&context_id,
&persona_did,
profile_id.as_deref(),
public_entries,
label.as_deref(),
expected_version,
)
.await?;
if !is_json_output() {
if clearing {
println!("{DIM}Taken off — {persona_did} now shows nothing in {context_id}.{RESET}");
} else {
println!(
"{DIM}A context only ever gets a copy: the face's values were written into \
{context_id}. Editing an attribute refreshes it; copies go down, and nothing reads \
up.{RESET}"
);
if unlabelled {
println!(
"{DIM}No --label: {context_id} is given no name for this face. Your own name \
for it stays yours.{RESET}"
);
}
}
}
print_result("Binding:", &result)
}
pub async fn cmd_binding_get(
client: &VtaClient,
context_id: String,
persona_did: String,
) -> CmdResult {
let result = client
.persona_binding_get(&context_id, &persona_did)
.await?;
print_result("Binding:", &result)
}
pub async fn cmd_binding_list(
client: &VtaClient,
context_id: String,
limit: Option<std::num::NonZeroU64>,
cursor: Option<String>,
) -> CmdResult {
let result = client
.persona_binding_list(&context_id, limit, cursor.as_deref())
.await?;
print_result("Bindings:", &result)
}
pub async fn cmd_contact_put(
client: &VtaClient,
context_id: String,
subject_did: String,
known_by_persona: String,
document: ContactDocument,
credential_refs: Vec<String>,
notes: Option<String>,
) -> CmdResult {
let result = client
.persona_contact_put(
&context_id,
&subject_did,
&known_by_persona,
document,
credential_refs,
notes.as_deref(),
)
.await?;
print_result("Contact:", &result)
}
pub async fn cmd_contact_get(
client: &VtaClient,
context_id: String,
contact_id: String,
rev: Option<std::num::NonZeroU64>,
include_history: bool,
) -> CmdResult {
let result = client
.persona_contact_get(&context_id, &contact_id, rev, include_history)
.await?;
print_result("Contact:", &result)
}
pub async fn cmd_contact_list(
client: &VtaClient,
context_id: String,
known_by_persona: Option<String>,
changed_since: Option<String>,
limit: Option<std::num::NonZeroU64>,
cursor: Option<String>,
) -> CmdResult {
let result = client
.persona_contact_list(
&context_id,
known_by_persona.as_deref(),
changed_since.as_deref(),
limit,
cursor.as_deref(),
)
.await?;
print_result("Contacts:", &result)
}
pub async fn cmd_contact_delete(
client: &VtaClient,
context_id: String,
contact_id: String,
) -> CmdResult {
let result = client
.persona_contact_delete(&context_id, &contact_id)
.await?;
print_result("Result:", &result)
}
pub async fn cmd_disclosure_preview(
client: &VtaClient,
context_id: String,
persona_did: String,
verifier_did: String,
requested_claims: Vec<String>,
purpose: Option<String>,
renderer: Option<String>,
) -> CmdResult {
let result = client
.persona_disclosure_preview(
&context_id,
&persona_did,
&verifier_did,
requested_claims,
purpose.as_deref(),
renderer.as_deref(),
)
.await?;
if is_json_output() {
print_json(&result)?;
return Ok(());
}
render_preview(&result, &context_id, &verifier_did, purpose.as_deref());
Ok(())
}
fn render_preview(result: &Value, context_id: &str, verifier_did: &str, purpose: Option<&str>) {
let (Some(preview_id), Some(claims)) = (
result.get("previewId").and_then(Value::as_str),
result.get("claims").and_then(Value::as_array),
) else {
println!(
"{YELLOW}Preview response was not in the expected shape; showing it as received.{RESET}"
);
println!(
"{}",
serde_json::to_string_pretty(result).unwrap_or_else(|_| format!("{result:?}"))
);
return;
};
println!();
println!("{BOLD}Before anything leaves{RESET} — nothing has been sent.");
println!();
println!(" {DIM}To{RESET} {verifier_did}");
println!(" {DIM}Context{RESET} {context_id}");
if let Some(p) = purpose {
println!(" {DIM}Purpose{RESET} {p}");
}
if let Some(subject) = result.get("subject").and_then(Value::as_str) {
println!(" {DIM}As{RESET} {subject}");
}
if let Some(r) = result.get("renderer") {
let id = r.get("id").and_then(Value::as_str).unwrap_or("?");
let drops: Vec<&str> = r
.get("drops")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
if drops.is_empty() {
println!(" {DIM}Format{RESET} {id} {DIM}(carries everything){RESET}");
} else {
println!(
" {DIM}Format{RESET} {id} {YELLOW}— discards: {}{RESET}",
drops.join(", ")
);
}
}
if let Some(c) = result.get("correlation") {
let severity = c.get("severity").and_then(Value::as_str).unwrap_or("none");
let colour = match severity {
"high" => RED,
"low" => YELLOW,
_ => DIM,
};
let reason = c.get("reason").and_then(Value::as_str).unwrap_or("");
println!(
" {DIM}Linked{RESET} {colour}{}{RESET} {reason}",
severity.to_uppercase()
);
}
let anomalous: Vec<&str> = result
.get("anomalous")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
println!();
if claims.is_empty() {
println!(" {DIM}No attributes would leave.{RESET}");
}
for claim in claims {
let ty = claim.get("type").and_then(Value::as_str).unwrap_or("?");
let provenance = claim
.get("provenance")
.and_then(Value::as_str)
.unwrap_or("");
let rung = claim.get("rung").and_then(Value::as_str).unwrap_or("");
let stale = claim.get("stale").and_then(Value::as_bool).unwrap_or(false);
let fresh = claim
.get("newToThisVerifier")
.and_then(Value::as_bool)
.unwrap_or(false);
let odd = anomalous.contains(&ty);
let shown = if let Some(p) = claim.get("predicate") {
let op = p.get("op").and_then(Value::as_str).unwrap_or("?");
let arg = p.get("arg").map(render_scalar).unwrap_or_default();
format!("{DIM}proves {op} {arg} — the value itself is not sent{RESET}")
} else if stale {
format!("{YELLOW}stale — will NOT be sent{RESET}")
} else {
claim.get("value").map(render_scalar).unwrap_or_default()
};
let mark = if odd || fresh {
format!("{RED}!{RESET}")
} else {
" ".into()
};
println!(" {mark} {CYAN}{ty}{RESET}");
println!(" {shown}");
let mut notes: Vec<String> = Vec::new();
if !provenance.is_empty() {
notes.push(provenance.to_string());
}
if !rung.is_empty() {
notes.push(rung.to_string());
}
if fresh {
notes.push("new to this verifier".into());
}
if odd {
notes.push("unusual for the stated purpose".into());
}
if !notes.is_empty() {
println!(" {DIM}{}{RESET}", notes.join(" · "));
}
}
println!();
if let Some(expiry) = result.get("expiresAt").and_then(Value::as_str) {
println!(" {DIM}This preview expires at {expiry} and can be used once.{RESET}");
}
println!(
" Disclose with: {BOLD}persona disclosure present --context {context_id} \
--preview-id {preview_id}{RESET}"
);
println!();
}
fn render_scalar(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
pub async fn cmd_disclosure_present(
client: &VtaClient,
context_id: String,
preview_id: String,
challenge: Option<String>,
mint: Option<Value>,
) -> CmdResult {
let result = client
.persona_disclosure_present(&context_id, &preview_id, challenge.as_deref(), mint)
.await?;
if !is_json_output()
&& result
.get("document")
.and_then(|d| d.get("unsigned"))
.and_then(Value::as_bool)
.unwrap_or(false)
{
println!(
"{YELLOW}This document is UNSIGNED. Sign it with the persona's key \
(`keys derive-and-sign-document`) before sending it to a verifier.{RESET}"
);
}
print_result("What left:", &result)
}
pub async fn cmd_disclosure_history(
client: &VtaClient,
context_id: Option<String>,
verifier_did: Option<String>,
attribute_type: Option<String>,
since: Option<String>,
limit: Option<std::num::NonZeroU64>,
cursor: Option<String>,
) -> CmdResult {
let result = client
.persona_disclosure_history(
context_id.as_deref(),
verifier_did.as_deref(),
attribute_type.as_deref(),
since.as_deref(),
limit,
cursor.as_deref(),
)
.await?;
print_result("What has left:", &result)
}
pub async fn cmd_correlate(
client: &VtaClient,
attribute_id: Option<String>,
profile_id: Option<String>,
candidate: Option<Value>,
) -> CmdResult {
let result = client
.persona_correlation_analyze(attribute_id.as_deref(), profile_id.as_deref(), candidate)
.await?;
print_result("Links:", &result)
}
pub async fn cmd_renderers(client: &VtaClient) -> CmdResult {
let result = client.persona_renderers_list().await?;
print_result("Renderers:", &result)
}
pub async fn cmd_local_profile_put(
client: &VtaClient,
context_id: String,
name: String,
entries: Vec<LocalProfileEntry>,
profile_id: Option<String>,
expected_version: Option<u64>,
) -> CmdResult {
let result = client
.persona_local_profile_put(
&context_id,
&name,
entries,
profile_id.as_deref(),
expected_version,
)
.await?;
print_result("Local profile:", &result)
}
pub async fn cmd_local_profile_get(
client: &VtaClient,
context_id: String,
profile_id: String,
) -> CmdResult {
let result = client
.persona_local_profile_get(&context_id, &profile_id)
.await?;
print_result("Local profile:", &result)
}
pub async fn cmd_local_profile_list(
client: &VtaClient,
context_id: String,
limit: Option<std::num::NonZeroU64>,
cursor: Option<String>,
) -> CmdResult {
let result = client
.persona_local_profile_list(&context_id, limit, cursor.as_deref())
.await?;
print_result("Local profiles:", &result)
}
pub async fn cmd_local_profile_delete(
client: &VtaClient,
context_id: String,
profile_id: String,
unbind: bool,
) -> CmdResult {
let result = client
.persona_local_profile_delete(&context_id, &profile_id, unbind)
.await?;
print_result("Result:", &result)
}
pub async fn cmd_local_binding_set(
client: &VtaClient,
context_id: String,
persona_did: String,
profile_id: Option<String>,
label: Option<String>,
expected_version: Option<u64>,
) -> CmdResult {
let result = client
.persona_local_binding_set(
&context_id,
&persona_did,
profile_id.as_deref(),
label.as_deref(),
expected_version,
)
.await?;
print_result("Local binding:", &result)
}